diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 1fb3a90a2..590a7c186 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -99,6 +99,11 @@ export { DebugRuleProps, DebugRuleRaws, } from './src/statement/debug-rule'; +export { + Declaration, + DeclarationProps, + DeclarationRaws, +} from './src/statement/declaration'; export {EachRule, EachRuleProps, EachRuleRaws} from './src/statement/each-rule'; export { ErrorRule, @@ -142,10 +147,12 @@ export { } from './src/statement/sass-comment'; export {UseRule, UseRuleProps, UseRuleRaws} from './src/statement/use-rule'; export { + AnyDeclaration, AnyStatement, AtRule, ChildNode, ChildProps, + Comment, ContainerProps, NewNode, Statement, @@ -196,7 +203,7 @@ class _Syntax implements Syntax { } stringify(node: postcss.AnyNode, builder: postcss.Builder): void { - new Stringifier(builder).stringify(node, true); + new Stringifier(builder).stringify(node, false); } } diff --git a/pkg/sass-parser/lib/src/parameter.ts b/pkg/sass-parser/lib/src/parameter.ts index 75864a0d3..5e31f31f7 100644 --- a/pkg/sass-parser/lib/src/parameter.ts +++ b/pkg/sass-parser/lib/src/parameter.ts @@ -61,15 +61,15 @@ export type ParameterObjectProps = NodeProps & { raws?: ParameterRaws; name: string; } & ( - | { - defaultValue?: Expression | ExpressionProps; - rest?: never; - } - | { - defaultValue?: never; - rest?: boolean; - } -); + | { + defaultValue?: Expression | ExpressionProps; + rest?: never; + } + | { + defaultValue?: never; + rest?: boolean; + } + ); /** * Properties used to initialize a {@link Parameter} without an explicit name. diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 5ff29808f..d9023b480 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -125,6 +125,11 @@ declare namespace SassInternal { readonly expression: Expression; } + class Declaration extends ParentStatement { + readonly name: Interpolation; + readonly value?: Expression; + } + class EachRule extends ParentStatement { readonly variables: string[]; readonly list: Expression; @@ -317,6 +322,7 @@ export type AtRootRule = SassInternal.AtRootRule; export type AtRule = SassInternal.AtRule; export type ContentBlock = SassInternal.ContentBlock; export type DebugRule = SassInternal.DebugRule; +export type Declaration = SassInternal.Declaration; export type EachRule = SassInternal.EachRule; export type ErrorRule = SassInternal.ErrorRule; export type ExtendRule = SassInternal.ExtendRule; @@ -350,6 +356,7 @@ export interface StatementVisitorObject { visitAtRootRule(node: AtRootRule): T; visitAtRule(node: AtRule): T; visitDebugRule(node: DebugRule): T; + visitDeclaration(node: Declaration): T; visitEachRule(node: EachRule): T; visitErrorRule(node: ErrorRule): T; visitExtendRule(node: ExtendRule): T; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/declaration.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/declaration.test.ts.snap new file mode 100644 index 000000000..6bc83ab7b --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/declaration.test.ts.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a property declaration toJSON with expression and no nodes 1`] = ` +{ + "expression": , + "inputs": [ + { + "css": "a {foo: bar}", + "hasBOM": false, + "id": "", + }, + ], + "propInterpolation": , + "raws": {}, + "sassType": "decl", + "source": <1:4-1:12 in 0>, + "type": "decl", +} +`; + +exports[`a property declaration toJSON with expression and nodes 1`] = ` +{ + "expression": , + "inputs": [ + { + "css": "a {foo: bar {baz: bang}}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + , + ], + "propInterpolation": , + "raws": {}, + "sassType": "decl", + "source": <1:4-1:24 in 0>, + "type": "decl", +} +`; + +exports[`a property declaration toJSON with no expression and nodes 1`] = ` +{ + "inputs": [ + { + "css": "a {foo: {baz: bang}}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + , + ], + "propInterpolation": , + "raws": {}, + "sassType": "decl", + "source": <1:4-1:20 in 0>, + "type": "decl", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap index 2f4c3dd15..769b88951 100644 --- a/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap @@ -12,7 +12,7 @@ exports[`a generic @-rule toJSON with a child 1`] = ` "name": "foo", "nameInterpolation": , "nodes": [ - <@bar;>, + <@bar>, ], "params": "", "raws": {}, diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap index 672e8a29a..74fed5c7a 100644 --- a/pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap @@ -13,7 +13,7 @@ exports[`a @include rule toJSON with a child 1`] = ` ], "name": "include", "nodes": [ - <@qux;>, + <@qux>, ], "params": "foo(bar)", "raws": {}, @@ -56,7 +56,7 @@ exports[`a @include rule toJSON with using and a child 1`] = ` ], "name": "include", "nodes": [ - <@qux;>, + <@qux>, ], "params": "foo(bar) using ($baz)", "raws": {}, diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap index 792fc7e23..9e431db88 100644 --- a/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap @@ -10,7 +10,7 @@ exports[`a style rule toJSON with a child 1`] = ` }, ], "nodes": [ - <@bar;>, + <@bar>, ], "raws": {}, "sassType": "rule", diff --git a/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts index e5395cdca..a29df8930 100644 --- a/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts +++ b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts @@ -6,7 +6,7 @@ import * as postcss from 'postcss'; import {Rule} from './rule'; import {Root} from './root'; -import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; +import {AnyDeclaration, AtRule, ChildNode, Comment, NewNode} from '.'; /** * A fake intermediate class to convince TypeScript to use Sass types for @@ -60,10 +60,10 @@ export class _AtRule extends postcss.AtRule { ): false | undefined; walkDecls( propFilter: RegExp | string, - callback: (decl: Declaration, index: number) => false | void, + callback: (decl: AnyDeclaration, index: number) => false | void, ): false | undefined; walkDecls( - callback: (decl: Declaration, index: number) => false | void, + callback: (decl: AnyDeclaration, index: number) => false | void, ): false | undefined; walkRules( selectorFilter: RegExp | string, diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.test.ts b/pkg/sass-parser/lib/src/statement/debug-rule.test.ts index a06b459aa..6fb21fac4 100644 --- a/pkg/sass-parser/lib/src/statement/debug-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/debug-rule.test.ts @@ -107,7 +107,7 @@ describe('a @debug rule', () => { new DebugRule({ debugExpression: {text: 'foo'}, }).toString(), - ).toBe('@debug foo;')); + ).toBe('@debug foo')); it('with afterName', () => expect( @@ -115,7 +115,7 @@ describe('a @debug rule', () => { debugExpression: {text: 'foo'}, raws: {afterName: '/**/'}, }).toString(), - ).toBe('@debug/**/foo;')); + ).toBe('@debug/**/foo')); it('with between', () => expect( @@ -123,7 +123,7 @@ describe('a @debug rule', () => { debugExpression: {text: 'foo'}, raws: {between: '/**/'}, }).toString(), - ).toBe('@debug foo/**/;')); + ).toBe('@debug foo/**/')); }); }); diff --git a/pkg/sass-parser/lib/src/statement/declaration-internal.d.ts b/pkg/sass-parser/lib/src/statement/declaration-internal.d.ts index 03f4e3ec8..540f83c6a 100644 --- a/pkg/sass-parser/lib/src/statement/declaration-internal.d.ts +++ b/pkg/sass-parser/lib/src/statement/declaration-internal.d.ts @@ -3,10 +3,11 @@ // https://opensource.org/licenses/MIT. import * as postcss from 'postcss'; +import {ContainerWithChildren} from 'postcss/lib/container'; import {Rule} from './rule'; import {Root} from './root'; -import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; +import {AnyDeclaration, AtRule, ChildNode, Comment, NewNode} from '.'; /** * A fake intermediate class to convince TypeScript to use Sass types for @@ -15,30 +16,81 @@ import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; * @hidden */ export class _Declaration extends postcss.Declaration { - // Override the PostCSS container types to constrain them to Sass types only. + // Override the PostCSS types to constrain them to Sass types only. // Unfortunately, there's no way to abstract this out, because anything // mixin-like returns an intersection type which doesn't actually override // parent methods. See microsoft/TypeScript#59394. after(newNode: NewNode): this; - append(...nodes: NewNode[]): this; assign(overrides: Partial): this; before(newNode: NewNode): this; cloneAfter(overrides?: Partial): this; cloneBefore(overrides?: Partial): this; + next(): ChildNode | undefined; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; +} + +// This functionally extends *both* `_Declaration` and +// `postcss.Container`, but because TypeScript doesn't support proper multiple +// inheritance and the latter has protected properties we need to explicitly +// extend it. + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _DeclarationWithChildren + extends postcss.Container + implements _Declaration +{ + declare parent: ContainerWithChildren | undefined; + declare type: 'decl'; + + get prop(): string; + get value(): string; + get important(): boolean; + get variable(): boolean; + + after(newNode: NewNode): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + clone(overrides?: Partial): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + next(): ChildNode | undefined; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; + + append(...nodes: NewNode[]): this; each( callback: (node: ChildNode, index: number) => false | void, ): false | undefined; every( condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean, ): boolean; + index(child: postcss.ChildNode | number): number; insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this; insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this; - next(): ChildNode | undefined; prepend(...nodes: NewNode[]): this; - prev(): ChildNode | undefined; - replaceWith(...nodes: NewNode[]): this; - root(): Root; + push(child: ChildNode): this; + removeAll(): this; + removeChild(child: postcss.ChildNode | number): this; + replaceValues( + pattern: RegExp | string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + replaced: {(substring: string, ...args: any[]): string} | string, + ): this; + replaceValues( + pattern: RegExp | string, + options: {fast?: string; props?: readonly string[]}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + replaced: {(substring: string, ...args: any[]): string} | string, + ): this; some( condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean, ): boolean; @@ -60,10 +112,10 @@ export class _Declaration extends postcss.Declaration { ): false | undefined; walkDecls( propFilter: RegExp | string, - callback: (decl: Declaration, index: number) => false | void, + callback: (decl: AnyDeclaration, index: number) => false | void, ): false | undefined; walkDecls( - callback: (decl: Declaration, index: number) => false | void, + callback: (decl: AnyDeclaration, index: number) => false | void, ): false | undefined; walkRules( selectorFilter: RegExp | string, diff --git a/pkg/sass-parser/lib/src/statement/declaration-internal.js b/pkg/sass-parser/lib/src/statement/declaration-internal.js index 8472c1ec9..5c8d52931 100644 --- a/pkg/sass-parser/lib/src/statement/declaration-internal.js +++ b/pkg/sass-parser/lib/src/statement/declaration-internal.js @@ -2,4 +2,14 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -exports._Declaration = require('postcss').Declaration; +const postcss = require('postcss'); + +exports._Declaration = postcss.Declaration; + +// Inject PostCSS's container implementation into a declaration subclass so we +// can define declarations that have child nodes. +class _DeclarationWithChildren extends postcss.Declaration {} +const containerProperties = Object.getOwnPropertyDescriptors(postcss.Container.prototype); +Object.defineProperties(_DeclarationWithChildren.prototype, containerProperties); + +exports._DeclarationWithChildren = _DeclarationWithChildren; diff --git a/pkg/sass-parser/lib/src/statement/declaration.test.ts b/pkg/sass-parser/lib/src/statement/declaration.test.ts new file mode 100644 index 000000000..e7e21feb2 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/declaration.test.ts @@ -0,0 +1,511 @@ +// Copyright 2024 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 { + Declaration, + Interpolation, + Rule, + StringExpression, + sass, + scss, +} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a property declaration', () => { + let node: Declaration; + beforeEach( + () => + void (node = new Declaration({ + prop: 'foo', + expression: {text: 'bar'}, + })), + ); + + describe('with no children', () => { + function describeNode( + description: string, + create: () => Declaration, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('decl')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('decl')); + + it('has propInterpolation', () => + expect(node).toHaveInterpolation('propInterpolation', 'foo')); + + it('has a prop', () => expect(node.prop).toBe('foo')); + + it('has an expression', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('has a value', () => expect(node.value).toBe('bar')); + + it('has no nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('a {foo: bar}').nodes[0] as Rule).nodes[0] as Declaration, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('a\n foo: bar').nodes[0] as Rule).nodes[0] as Declaration, + ); + + describe('constructed manually', () => { + describeNode( + 'with prop and value', + () => + new Declaration({ + prop: 'foo', + value: 'bar', + }), + ); + + describe('with propInterpolation', () => { + describeNode( + "that's a string", + () => + new Declaration({ + propInterpolation: 'foo', + value: 'bar', + }), + ); + + describeNode( + "that's child props", + () => + new Declaration({ + propInterpolation: {nodes: ['foo']}, + value: 'bar', + }), + ); + + describeNode( + "that's an explicit Interpolation", + () => + new Declaration({ + propInterpolation: new Interpolation('foo'), + value: 'bar', + }), + ); + }); + + describe('with an expression', () => { + describeNode( + "that's child props", + () => + new Declaration({ + prop: 'foo', + expression: {text: 'bar'}, + }), + ); + + describeNode( + "that's an Expression", + () => + new Declaration({ + prop: 'foo', + expression: new StringExpression({text: 'bar'}), + }), + ); + }); + }); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({prop: 'foo', value: 'bar'}), + ); + }); + + describe('with a value and children', () => { + function describeNode( + description: string, + create: () => Declaration, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('decl')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('decl')); + + it('has propInterpolation', () => + expect(node).toHaveInterpolation('propInterpolation', 'foo')); + + it('has a prop', () => expect(node.prop).toBe('foo')); + + it('has an expression', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('has a value', () => expect(node.value).toBe('bar')); + + it('has nodes', () => expect(node.nodes).toHaveLength(1)); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('a {foo: bar {baz: bang}}').nodes[0] as Rule) + .nodes[0] as Declaration, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('a\n foo: bar\n baz: bang').nodes[0] as Rule) + .nodes[0] as Declaration, + ); + + describeNode( + 'constructed manually', + () => + new Declaration({ + prop: 'foo', + value: 'bar', + nodes: [{name: 'baz'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({prop: 'foo', value: 'bar', nodes: [{name: 'baz'}]}), + ); + }); + + it('assigned a new prop', () => { + node.prop = 'baz'; + expect(node.prop).toBe('baz'); + expect(node).toHaveInterpolation('propInterpolation', 'baz'); + }); + + it('assigned a new propInterpolation', () => { + node.propInterpolation = 'baz'; + expect(node.prop).toBe('baz'); + expect(node).toHaveInterpolation('propInterpolation', 'baz'); + }); + + it('assigned a new expression', () => { + const old = node.expression!; + node.expression = {text: 'baz'}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('expression', 'baz'); + expect(node.value).toBe('baz'); + }); + + it('assigned a value', () => { + node.value = 'Helvetica, sans-serif'; + expect(node).toHaveStringExpression('expression', 'Helvetica, sans-serif'); + expect(node.value).toBe('Helvetica, sans-serif'); + }); + + it('is not a variable without --', () => expect(node.variable).toBe(false)); + + it('is a variable with --', () => { + node.prop = '--foo'; + expect(node.variable).toBe(true); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no children', () => + expect(new Declaration({prop: 'foo', value: 'bar'}).toString()).toBe( + 'foo: bar', + )); + + it('with a value and children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + nodes: [{prop: 'baz', value: 'bang'}], + }).toString(), + ).toBe('foo: bar {\n baz: bang\n}')); + + it('with only children', () => + expect( + new Declaration({ + prop: 'foo', + nodes: [{prop: 'baz', value: 'bang'}], + }).toString(), + ).toBe('foo: {\n baz: bang\n}')); + }); + + describe('with before', () => { + it('on its own', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + raws: {before: '/**/'}, + }).toString(), + ).toBe('foo: bar')); + + it('as a child', () => + expect( + new Rule({ + selector: 'foo', + nodes: [{prop: 'bar', value: 'baz', raws: {before: '/**/'}}], + }).toString(), + ).toBe('foo {/**/bar: baz\n}')); + }); + + describe('with between', () => { + it('with no children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + raws: {between: ':/**/'}, + }).toString(), + ).toBe('foo:/**/bar')); + + it('with a value and children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + nodes: [{prop: 'baz', value: 'bang'}], + raws: {between: ':/**/'}, + }).toString(), + ).toBe('foo:/**/bar {\n baz: bang\n}')); + + it('with no value and children', () => + expect( + new Declaration({ + prop: 'foo', + nodes: [{prop: 'baz', value: 'bang'}], + raws: {between: ':/**/'}, + }).toString(), + ).toBe('foo:/**/ {\n baz: bang\n}')); + }); + + it('ignores important', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar !important', + raws: {important: '!IMPORTANT'}, + }).toString(), + ).toBe('foo: bar !important')); + + it('ignores value', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + raws: {value: {value: 'bar', raw: 'BAR'}}, + }).toString(), + ).toBe('foo: bar')); + + describe('with afterValue', () => { + it('with no children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + raws: {afterValue: '/**/'}, + }).toString(), + ).toBe('foo: bar/**/')); + + it('with a value and children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + nodes: [{prop: 'baz', value: 'bang'}], + raws: {afterValue: '/**/'}, + }).toString(), + ).toBe('foo: bar/**/{\n baz: bang\n}')); + + it('with no value and children', () => + expect( + new Declaration({ + prop: 'foo', + nodes: [{prop: 'baz', value: 'bang'}], + raws: {afterValue: '/**/'}, + }).toString(), + ).toBe('foo:/**/{\n baz: bang\n}')); + }); + + describe('with after', () => { + it('without children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + raws: {after: '/**/'}, + }).toString(), + ).toBe('foo: bar')); + + it('with children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + nodes: [{prop: 'baz', value: 'bang'}], + raws: {after: '/**/'}, + }).toString(), + ).toBe('foo: bar {\n baz: bang/**/}')); + }); + + describe('with ownSemicolon', () => { + it('without children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + raws: {ownSemicolon: ';'}, + }).toString(), + ).toBe('foo: bar')); + + it('with children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + nodes: [{prop: 'baz', value: 'bang'}], + raws: {ownSemicolon: ';'}, + }).toString(), + ).toBe('foo: bar {\n baz: bang\n};')); + }); + + describe('with semicolon', () => { + it('without children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + raws: {semicolon: true}, + }).toString(), + ).toBe('foo: bar')); + + it('with children', () => + expect( + new Declaration({ + prop: 'foo', + value: 'bar', + nodes: [{prop: 'baz', value: 'bang'}], + raws: {semicolon: true}, + }).toString(), + ).toBe('foo: bar {\n baz: bang;\n}')); + }); + }); + }); + + describe('clone', () => { + let original: Declaration; + beforeEach(() => { + original = (scss.parse('a {foo: bar {baz: bang}}').nodes[0] as Rule) + .nodes[0] as Declaration; + // TODO: remove this once raws are properly parsed + original.raws.between = ' :'; + }); + + describe('with no overrides', () => { + let clone: Declaration; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('prop', () => expect(clone.prop).toBe('foo')); + + it('propInterpolation', () => + expect(clone).toHaveInterpolation('propInterpolation', 'foo')); + + it('expression', () => + expect(clone).toHaveStringExpression('expression', 'bar')); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of [ + 'propInterpolation', + 'expression', + 'raws', + ] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterValue: ' '}}).raws).toEqual({ + afterValue: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' :', + })); + }); + + describe('propInterpolation', () => { + describe('defined', () => { + let clone: Declaration; + beforeEach(() => { + clone = original.clone({propInterpolation: 'zap'}); + }); + + it('changes variableName', () => + expect(clone).toHaveInterpolation('propInterpolation', 'zap')); + + it('changes prop', () => expect(clone.prop).toBe('zap')); + }); + + describe('undefined', () => { + let clone: Declaration; + beforeEach(() => { + clone = original.clone({propInterpolation: undefined}); + }); + + it('preserves variableName', () => + expect(clone).toHaveInterpolation('propInterpolation', 'foo')); + + it('preserves prop', () => expect(clone.prop).toBe('foo')); + }); + }); + + describe('expression', () => { + it('defined changes expression', () => + expect( + original.clone({expression: {text: 'zap'}}), + ).toHaveStringExpression('expression', 'zap')); + + it('undefined removes expression', () => + expect( + original.clone({expression: undefined}).expression, + ).toBeUndefined()); + }); + }); + }); + + describe('toJSON', () => { + it('with expression and nodes', () => + expect( + (scss.parse('a {foo: bar {baz: bang}}').nodes[0] as Rule).nodes[0], + ).toMatchSnapshot()); + + it('with expression and no nodes', () => + expect( + (scss.parse('a {foo: bar}').nodes[0] as Rule).nodes[0], + ).toMatchSnapshot()); + + it('with no expression and nodes', () => + expect( + (scss.parse('a {foo: {baz: bang}}').nodes[0] as Rule).nodes[0], + ).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/declaration.ts b/pkg/sass-parser/lib/src/statement/declaration.ts new file mode 100644 index 000000000..3446d945f --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/declaration.ts @@ -0,0 +1,249 @@ +// Copyright 2024 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 {DeclarationRaws as PostcssDeclarationRaws} from 'postcss/lib/declaration'; + +import {Expression, ExpressionProps} from '../expression'; +import {Interpolation, InterpolationProps} from '../interpolation'; +import {convertExpression} from '../expression/convert'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import {Node} from '../node'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ChildProps, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {_DeclarationWithChildren} from './declaration-internal'; +import * as sassParser from '../..'; + +// TODO(nweiz): Make sure setting non-identifier strings for prop here and name +// in GenericAtRule escapes properly. + +/** + * The set of raws supported by {@link Declaration}. + * + * @category Statement + */ +export interface DeclarationRaws + extends Omit { + /** + * The space symbols between the end of the declaration's value and the + * semicolon or the opening `{`. Always empty for a declaration that isn't + * followed by a semicolon, and ignored if the declaration has children but no + * value. + */ + afterValue?: string; + + /** + * The space symbols between the last child of the node and the `}`. Ignored + * if the declaration has no children. + */ + after?: string; + + /** + * The text of the semicolon after the declaration's children. Ignored if the + * declaration has no children. + */ + ownSemicolon?: string; + + /** + * Contains `true` if the last child has an (optional) semicolon. Ignored if + * the declaration has no children. + */ + semicolon?: boolean; +} + +/** + * The initializer properties for {@link Declaration}. + * + * @category Statement + */ +export type DeclarationProps = ContainerProps & { + raws?: DeclarationRaws; +} & ( + | {propInterpolation: Interpolation | InterpolationProps; prop?: never} + | {prop: string; propInterpolation?: never} + ) & + ( + | {expression: Expression | ExpressionProps; value?: never} + | {value: string; expression?: never} + // `expression` and `value` are optional, but *only* if `nodes` is passed + // explicitly. This also allows `nodes` to be passed along with + // `expressions` or `values` because of the top-level `ContainerProps &`. + | {nodes: ReadonlyArray} + ); + +/** + * A Sass property declaration. Extends [`postcss.Declaration`]. + * + * [`postcss.Declaration`]: https://postcss.org/api/#declaration + * + * @category Statement + */ +export class Declaration + extends _DeclarationWithChildren> + implements Statement +{ + readonly sassType = 'decl' as const; + declare parent: StatementWithChildren | undefined; + declare raws: DeclarationRaws; + declare nodes: ChildNode[] | undefined; + + get prop(): string { + return this.propInterpolation.toString(); + } + set prop(value: string) { + this.propInterpolation = value; + } + + /** + * The interpolation that represents this declaration's property prop. + */ + get propInterpolation(): Interpolation { + return this._propInterpolation!; + } + set propInterpolation(value: Interpolation | InterpolationProps) { + if (this._propInterpolation) this._propInterpolation.parent = undefined; + const propInterpolation = + value instanceof Interpolation ? value : new Interpolation(value); + propInterpolation.parent = this; + this._propInterpolation = propInterpolation; + } + private declare _propInterpolation?: Interpolation; + + /** + * The declaration's value. + * + * **Note:** In Sass, custom properties can't have SassScript values without + * being surrounded by interpolation. Custom properties are always parsed as + * unquoted string values, and if they're set to other SassScript values they + * may not be evaluated as expected. + */ + get expression(): Expression | undefined { + return this._expression; + } + set expression(value: Expression | ExpressionProps | undefined) { + if (this._expression) this._expression.parent = undefined; + if (value) { + if (!('sassType' in value)) value = fromProps(value); + if (value) value.parent = this; + } + this._expression = value; + } + private declare _expression?: Expression; + + get value(): string { + return this.expression?.toString() ?? ''; + } + set value(value: string | undefined) { + this.expression = value === undefined ? undefined : {text: value}; + } + + get important(): boolean { + // TODO: Return whether `this.expression` is a nested series of unbracketed + // list expressions that ends in the unquoted string `!important` (or an + // unquoted string ending in " !important", which can occur if `value` is + // set // manually). + throw new Error('Not yet implemented'); + } + set important(value: boolean) { + // TODO: If value !== this.important, either set this to a space-separated + // list whose second value is `!important` or remove the existing + // `!important` from wherever it's defined. Or if that's too complex, just + // bake this to a string expression and edit that. + throw new Error('Not yet implemented'); + } + + get variable(): boolean { + const first = this.propInterpolation.nodes[0]; + return typeof first === 'string' && first.startsWith('--'); + } + + /** + * Iterators that are currently active within this declaration's children. + * Their indices refer to the last position that has already been sent to the + * callback, and are updated when {@link _nodes} is modified. + */ + readonly #iterators: Array<{index: number}> = []; + + constructor(defaults: DeclarationProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.Declaration); + constructor(defaults?: DeclarationProps, inner?: sassInternal.Declaration) { + super(defaults as unknown as postcss.DeclarationProps); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.propInterpolation = new Interpolation(undefined, inner.name); + if (inner.value) { + this.expression = convertExpression(inner.value); + } + appendInternalChildren(this, inner.children); + } + } + + append(...children: NewNode[]): this { + this.nodes ??= []; + return super.append(...children); + } + + prepend(...children: NewNode[]): this { + this.nodes ??= []; + return super.prepend(...children); + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + [ + 'raws', + 'propInterpolation', + {name: 'expression', explicitUndefined: true}, + ], + ['prop', 'value'], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['propInterpolation', 'expression', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + const result: Node[] = [this.propInterpolation]; + if (this.expression) result.push(this.expression); + return result; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this as StatementWithChildren, node, sample); + } +} diff --git a/pkg/sass-parser/lib/src/statement/error-rule.test.ts b/pkg/sass-parser/lib/src/statement/error-rule.test.ts index 57b33aac5..38dd19c87 100644 --- a/pkg/sass-parser/lib/src/statement/error-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/error-rule.test.ts @@ -107,7 +107,7 @@ describe('a @error rule', () => { new ErrorRule({ errorExpression: {text: 'foo'}, }).toString(), - ).toBe('@error foo;')); + ).toBe('@error foo')); it('with afterName', () => expect( @@ -115,7 +115,7 @@ describe('a @error rule', () => { errorExpression: {text: 'foo'}, raws: {afterName: '/**/'}, }).toString(), - ).toBe('@error/**/foo;')); + ).toBe('@error/**/foo')); it('with between', () => expect( @@ -123,7 +123,7 @@ describe('a @error rule', () => { errorExpression: {text: 'foo'}, raws: {between: '/**/'}, }).toString(), - ).toBe('@error foo/**/;')); + ).toBe('@error foo/**/')); }); }); diff --git a/pkg/sass-parser/lib/src/statement/forward-rule.test.ts b/pkg/sass-parser/lib/src/statement/forward-rule.test.ts index 08a81f843..6a4baa712 100644 --- a/pkg/sass-parser/lib/src/statement/forward-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/forward-rule.test.ts @@ -437,7 +437,7 @@ describe('a @forward rule', () => { forwardUrl: 'foo', prefix: 'bar-', }).toString(), - ).toBe('@forward "foo" as bar-*;')); + ).toBe('@forward "foo" as bar-*')); it('with a non-identifier prefix', () => expect( @@ -445,7 +445,7 @@ describe('a @forward rule', () => { forwardUrl: 'foo', prefix: ' ', }).toString(), - ).toBe('@forward "foo" as \\20*;')); + ).toBe('@forward "foo" as \\20*')); it('with show', () => expect( @@ -453,7 +453,7 @@ describe('a @forward rule', () => { forwardUrl: 'foo', show: {mixinsAndFunctions: ['bar'], variables: ['baz', 'qux']}, }).toString(), - ).toBe('@forward "foo" show bar, $baz, $qux;')); + ).toBe('@forward "foo" show bar, $baz, $qux')); it('with a non-identifier show', () => expect( @@ -461,7 +461,7 @@ describe('a @forward rule', () => { forwardUrl: 'foo', show: {mixinsAndFunctions: [' ']}, }).toString(), - ).toBe('@forward "foo" show \\20;')); + ).toBe('@forward "foo" show \\20')); it('with hide', () => expect( @@ -469,7 +469,7 @@ describe('a @forward rule', () => { forwardUrl: 'foo', hide: {mixinsAndFunctions: ['bar'], variables: ['baz', 'qux']}, }).toString(), - ).toBe('@forward "foo" hide bar, $baz, $qux;')); + ).toBe('@forward "foo" hide bar, $baz, $qux')); it('with a non-identifier hide', () => expect( @@ -477,7 +477,7 @@ describe('a @forward rule', () => { forwardUrl: 'foo', hide: {mixinsAndFunctions: [' ']}, }).toString(), - ).toBe('@forward "foo" hide \\20;')); + ).toBe('@forward "foo" hide \\20')); it('with configuration', () => expect( @@ -487,7 +487,7 @@ describe('a @forward rule', () => { variables: {bar: {text: 'baz', quotes: true}}, }, }).toString(), - ).toBe('@forward "foo" with ($bar: "baz");')); + ).toBe('@forward "foo" with ($bar: "baz")')); }); describe('with a URL raw', () => { @@ -497,7 +497,7 @@ describe('a @forward rule', () => { forwardUrl: 'foo', raws: {url: {raw: "'foo'", value: 'foo'}}, }).toString(), - ).toBe("@forward 'foo';")); + ).toBe("@forward 'foo'")); it("that doesn't match", () => expect( @@ -505,7 +505,7 @@ describe('a @forward rule', () => { forwardUrl: 'foo', raws: {url: {raw: "'bar'", value: 'bar'}}, }).toString(), - ).toBe('@forward "foo";')); + ).toBe('@forward "foo"')); }); describe('with a prefix raw', () => { @@ -516,7 +516,7 @@ describe('a @forward rule', () => { prefix: 'bar-', raws: {prefix: {raw: ' as bar-*', value: 'bar-'}}, }).toString(), - ).toBe('@forward "foo" as bar-*;')); + ).toBe('@forward "foo" as bar-*')); it("that doesn't match", () => expect( @@ -525,7 +525,7 @@ describe('a @forward rule', () => { prefix: 'baz-', raws: {url: {raw: ' as bar-*', value: 'bar-'}}, }).toString(), - ).toBe('@forward "foo" as baz-*;')); + ).toBe('@forward "foo" as baz-*')); }); describe('with show', () => { @@ -544,7 +544,7 @@ describe('a @forward rule', () => { }, }, }).toString(), - ).toBe('@forward "foo" show bar, baz;')); + ).toBe('@forward "foo" show bar, baz')); it('that has an extra member', () => expect( @@ -561,7 +561,7 @@ describe('a @forward rule', () => { }, }, }).toString(), - ).toBe('@forward "foo" show bar, baz;')); + ).toBe('@forward "foo" show bar, baz')); it("that's missing a member", () => expect( @@ -578,7 +578,7 @@ describe('a @forward rule', () => { }, }, }).toString(), - ).toBe('@forward "foo" show bar, baz;')); + ).toBe('@forward "foo" show bar, baz')); }); describe('with hide', () => { @@ -597,7 +597,7 @@ describe('a @forward rule', () => { }, }, }).toString(), - ).toBe('@forward "foo" hide bar, baz;')); + ).toBe('@forward "foo" hide bar, baz')); it('that has an extra member', () => expect( @@ -614,7 +614,7 @@ describe('a @forward rule', () => { }, }, }).toString(), - ).toBe('@forward "foo" hide bar, baz;')); + ).toBe('@forward "foo" hide bar, baz')); it("that's missing a member", () => expect( @@ -631,7 +631,7 @@ describe('a @forward rule', () => { }, }, }).toString(), - ).toBe('@forward "foo" hide bar, baz;')); + ).toBe('@forward "foo" hide bar, baz')); }); describe('with beforeWith', () => { @@ -644,7 +644,7 @@ describe('a @forward rule', () => { }, raws: {beforeWith: '/**/'}, }).toString(), - ).toBe('@forward "foo"/**/with ($bar: "baz");')); + ).toBe('@forward "foo"/**/with ($bar: "baz")')); it('and no configuration', () => expect( @@ -652,7 +652,7 @@ describe('a @forward rule', () => { forwardUrl: 'foo', raws: {beforeWith: '/**/'}, }).toString(), - ).toBe('@forward "foo";')); + ).toBe('@forward "foo"')); }); describe('with afterWith', () => { @@ -665,7 +665,7 @@ describe('a @forward rule', () => { }, raws: {afterWith: '/**/'}, }).toString(), - ).toBe('@forward "foo" with/**/($bar: "baz");')); + ).toBe('@forward "foo" with/**/($bar: "baz")')); it('and no configuration', () => expect( @@ -673,7 +673,7 @@ describe('a @forward rule', () => { forwardUrl: 'foo', raws: {afterWith: '/**/'}, }).toString(), - ).toBe('@forward "foo";')); + ).toBe('@forward "foo"')); }); }); }); diff --git a/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts index 9d6494e52..15279deb6 100644 --- a/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts @@ -377,7 +377,7 @@ describe('a generic @-rule', () => { describe('with undefined nodes', () => { describe('without params', () => { it('with default raws', () => - expect(new GenericAtRule({name: 'foo'}).toString()).toBe('@foo;')); + expect(new GenericAtRule({name: 'foo'}).toString()).toBe('@foo')); it('with afterName', () => expect( @@ -385,7 +385,7 @@ describe('a generic @-rule', () => { name: 'foo', raws: {afterName: '/**/'}, }).toString(), - ).toBe('@foo/**/;')); + ).toBe('@foo/**/')); it('with afterName', () => expect( @@ -393,7 +393,7 @@ describe('a generic @-rule', () => { name: 'foo', raws: {afterName: '/**/'}, }).toString(), - ).toBe('@foo/**/;')); + ).toBe('@foo/**/')); it('with between', () => expect( @@ -401,7 +401,7 @@ describe('a generic @-rule', () => { name: 'foo', raws: {between: '/**/'}, }).toString(), - ).toBe('@foo/**/;')); + ).toBe('@foo/**/')); it('with afterName and between', () => expect( @@ -409,7 +409,7 @@ describe('a generic @-rule', () => { name: 'foo', raws: {afterName: '/*afterName*/', between: '/*between*/'}, }).toString(), - ).toBe('@foo/*afterName*//*between*/;')); + ).toBe('@foo/*afterName*//*between*/')); }); describe('with params', () => { @@ -419,7 +419,7 @@ describe('a generic @-rule', () => { name: 'foo', paramsInterpolation: 'baz', }).toString(), - ).toBe('@foo baz;')); + ).toBe('@foo baz')); it('with afterName', () => expect( @@ -428,7 +428,7 @@ describe('a generic @-rule', () => { paramsInterpolation: 'baz', raws: {afterName: '/**/'}, }).toString(), - ).toBe('@foo/**/baz;')); + ).toBe('@foo/**/baz')); it('with between', () => expect( @@ -437,13 +437,13 @@ describe('a generic @-rule', () => { paramsInterpolation: 'baz', raws: {between: '/**/'}, }).toString(), - ).toBe('@foo baz/**/;')); + ).toBe('@foo baz/**/')); }); it('with after', () => expect( new GenericAtRule({name: 'foo', raws: {after: '/**/'}}).toString(), - ).toBe('@foo;')); + ).toBe('@foo')); it('with before', () => expect( 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 724df29d5..178d65f18 100644 --- a/pkg/sass-parser/lib/src/statement/include-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/include-rule.test.ts @@ -280,7 +280,7 @@ describe('a @include rule', () => { describe('with default raws', () => { it('with no arguments', () => expect(new IncludeRule({includeName: 'foo'}).toString()).toBe( - '@include foo;', + '@include foo', )); it('with an argument', () => @@ -289,7 +289,7 @@ describe('a @include rule', () => { includeName: 'foo', arguments: [{text: 'bar'}], }).toString(), - ).toBe('@include foo(bar);')); + ).toBe('@include foo(bar)')); it('with empty using', () => expect( @@ -315,7 +315,7 @@ describe('a @include rule', () => { includeName: 'f o', arguments: [{text: 'bar'}], }).toString(), - ).toBe('@include f\\20o(bar);')); + ).toBe('@include f\\20o(bar)')); }); it('with afterName', () => @@ -324,7 +324,7 @@ describe('a @include rule', () => { includeName: 'foo', raws: {afterName: '/**/'}, }).toString(), - ).toBe('@include/**/foo;')); + ).toBe('@include/**/foo')); it('with matching includeName', () => expect( @@ -332,7 +332,7 @@ describe('a @include rule', () => { includeName: 'foo', raws: {includeName: {value: 'foo', raw: 'f\\6fo'}}, }).toString(), - ).toBe('@include f\\6fo;')); + ).toBe('@include f\\6fo')); it('with non-matching includeName', () => expect( @@ -340,7 +340,7 @@ describe('a @include rule', () => { includeName: 'foo', raws: {includeName: {value: 'fao', raw: 'f\\41o'}}, }).toString(), - ).toBe('@include foo;')); + ).toBe('@include foo')); it('with showArguments = true', () => expect( @@ -348,7 +348,7 @@ describe('a @include rule', () => { includeName: 'foo', raws: {showArguments: true}, }).toString(), - ).toBe('@include foo();')); + ).toBe('@include foo()')); it('ignores showArguments with an argument', () => expect( @@ -357,7 +357,7 @@ describe('a @include rule', () => { arguments: [{text: 'bar'}], raws: {showArguments: true}, }).toString(), - ).toBe('@include foo(bar);')); + ).toBe('@include foo(bar)')); describe('with afterArguments', () => { it('with no using', () => @@ -367,7 +367,7 @@ describe('a @include rule', () => { arguments: [{text: 'bar'}], raws: {afterArguments: '/**/'}, }).toString(), - ).toBe('@include foo(bar);')); + ).toBe('@include foo(bar)')); it('with using', () => expect( @@ -387,7 +387,7 @@ describe('a @include rule', () => { using: ['baz'], raws: {afterArguments: '/**/'}, }).toString(), - ).toBe('@include foo/**/using ($baz);')); + ).toBe('@include foo/**/using ($baz)')); }); describe('with afterUsing', () => { @@ -398,7 +398,7 @@ describe('a @include rule', () => { arguments: [{text: 'bar'}], raws: {afterUsing: '/**/'}, }).toString(), - ).toBe('@include foo(bar);')); + ).toBe('@include foo(bar)')); it('with using', () => expect( @@ -407,7 +407,7 @@ describe('a @include rule', () => { using: ['baz'], raws: {afterUsing: '/**/'}, }).toString(), - ).toBe('@include foo using/**/($baz);')); + ).toBe('@include foo using/**/($baz)')); }); }); }); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index 92dd22f48..efaaa0ef0 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -13,6 +13,7 @@ import {CssComment, CssCommentProps} from './css-comment'; import {SassComment, SassCommentChildProps} from './sass-comment'; import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule'; import {DebugRule, DebugRuleProps} from './debug-rule'; +import {Declaration, DeclarationProps} from './declaration'; import {EachRule, EachRuleProps} from './each-rule'; import {ErrorRule, ErrorRuleProps} from './error-rule'; import {ForRule, ForRuleProps} from './for-rule'; @@ -31,16 +32,12 @@ import { import {WarnRule, WarnRuleProps} from './warn-rule'; import {WhileRule, WhileRuleProps} from './while-rule'; -// TODO: Replace this with the corresponding Sass types once they're -// implemented. -export {Declaration} from 'postcss'; - /** * The union type of all Sass statements. * * @category Statement */ -export type AnyStatement = Comment | Root | Rule | AtRule | VariableDeclaration; +export type AnyStatement = Comment | Root | Rule | AtRule | AnyDeclaration; /** * Sass statement types. @@ -56,6 +53,7 @@ export type StatementType = | 'rule' | 'atrule' | 'comment' + | 'decl' | 'debug-rule' | 'each-rule' | 'error-rule' @@ -91,6 +89,13 @@ export type AtRule = | WarnRule | WhileRule; +/** + * All Sass statements that are declarations. + * + * @category Statement + */ +export type AnyDeclaration = VariableDeclaration | Declaration; + /** * All Sass statements that are comments. * @@ -105,7 +110,7 @@ export type Comment = CssComment | SassComment; * * @category Statement */ -export type ChildNode = Rule | AtRule | Comment | VariableDeclaration; +export type ChildNode = Rule | AtRule | Comment | AnyDeclaration; /** * The properties that can be used to construct {@link ChildNode}s. @@ -118,6 +123,7 @@ export type ChildProps = | postcss.ChildProps | CssCommentProps | DebugRuleProps + | DeclarationProps | EachRuleProps | ErrorRuleProps | ForRuleProps @@ -183,6 +189,7 @@ const visitor = sassInternal.createStatementVisitor({ }, visitAtRule: inner => new GenericAtRule(undefined, inner), visitDebugRule: inner => new DebugRule(undefined, inner), + visitDeclaration: inner => new Declaration(undefined, inner), visitErrorRule: inner => new ErrorRule(undefined, inner), visitEachRule: inner => new EachRule(undefined, inner), visitForRule: inner => new ForRule(undefined, inner), @@ -322,6 +329,8 @@ export function normalize( } } else if ('type' in node) { result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); + } else if ('prop' in node || 'propInterpolation' in node) { + result.push(new Declaration(node)); } else if ( 'selectorInterpolation' in node || 'selector' in node || diff --git a/pkg/sass-parser/lib/src/statement/return-rule.test.ts b/pkg/sass-parser/lib/src/statement/return-rule.test.ts index eaf371870..f49099f11 100644 --- a/pkg/sass-parser/lib/src/statement/return-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/return-rule.test.ts @@ -113,7 +113,7 @@ describe('a @return rule', () => { new ReturnRule({ returnExpression: {text: 'foo'}, }).toString(), - ).toBe('@return foo;')); + ).toBe('@return foo')); it('with afterName', () => expect( @@ -121,7 +121,7 @@ describe('a @return rule', () => { returnExpression: {text: 'foo'}, raws: {afterName: '/**/'}, }).toString(), - ).toBe('@return/**/foo;')); + ).toBe('@return/**/foo')); it('with between', () => expect( @@ -129,7 +129,7 @@ describe('a @return rule', () => { returnExpression: {text: 'foo'}, raws: {between: '/**/'}, }).toString(), - ).toBe('@return foo/**/;')); + ).toBe('@return foo/**/')); }); }); diff --git a/pkg/sass-parser/lib/src/statement/root-internal.d.ts b/pkg/sass-parser/lib/src/statement/root-internal.d.ts index 7306eeeb2..70e435e7d 100644 --- a/pkg/sass-parser/lib/src/statement/root-internal.d.ts +++ b/pkg/sass-parser/lib/src/statement/root-internal.d.ts @@ -6,7 +6,7 @@ import * as postcss from 'postcss'; import {Rule} from './rule'; import {Root, RootProps} from './root'; -import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; +import {AnyDeclaration, AtRule, ChildNode, Comment, NewNode} from '.'; /** * A fake intermediate class to convince TypeScript to use Sass types for @@ -62,10 +62,10 @@ export class _Root extends postcss.Root { ): false | undefined; walkDecls( propFilter: RegExp | string, - callback: (decl: Declaration, index: number) => false | void, + callback: (decl: AnyDeclaration, index: number) => false | void, ): false | undefined; walkDecls( - callback: (decl: Declaration, index: number) => false | void, + callback: (decl: AnyDeclaration, index: number) => false | void, ): false | undefined; walkRules( selectorFilter: RegExp | string, diff --git a/pkg/sass-parser/lib/src/statement/rule-internal.d.ts b/pkg/sass-parser/lib/src/statement/rule-internal.d.ts index 89f4e1a00..7fa8a1700 100644 --- a/pkg/sass-parser/lib/src/statement/rule-internal.d.ts +++ b/pkg/sass-parser/lib/src/statement/rule-internal.d.ts @@ -6,7 +6,7 @@ import * as postcss from 'postcss'; import {Rule, RuleProps} from './rule'; import {Root} from './root'; -import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; +import {AnyDeclaration, AtRule, ChildNode, Comment, NewNode} from '.'; /** * A fake intermediate class to convince TypeScript to use Sass types for @@ -62,10 +62,10 @@ export class _Rule extends postcss.Rule { ): false | undefined; walkDecls( propFilter: RegExp | string, - callback: (decl: Declaration, index: number) => false | void, + callback: (decl: AnyDeclaration, index: number) => false | void, ): false | undefined; walkDecls( - callback: (decl: Declaration, index: number) => false | void, + callback: (decl: AnyDeclaration, index: number) => false | void, ): false | undefined; walkRules( selectorFilter: RegExp | string, diff --git a/pkg/sass-parser/lib/src/statement/use-rule.test.ts b/pkg/sass-parser/lib/src/statement/use-rule.test.ts index 2786a3033..936ef1208 100644 --- a/pkg/sass-parser/lib/src/statement/use-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/use-rule.test.ts @@ -275,7 +275,7 @@ describe('a @use rule', () => { useUrl: 'foo', namespace: 'bar', }).toString(), - ).toBe('@use "foo" as bar;')); + ).toBe('@use "foo" as bar')); it('with a non-identifier namespace', () => expect( @@ -283,7 +283,7 @@ describe('a @use rule', () => { useUrl: 'foo', namespace: ' ', }).toString(), - ).toBe('@use "foo" as \\20;')); + ).toBe('@use "foo" as \\20')); it('with no namespace', () => expect( @@ -291,7 +291,7 @@ describe('a @use rule', () => { useUrl: 'foo', namespace: null, }).toString(), - ).toBe('@use "foo" as *;')); + ).toBe('@use "foo" as *')); it('with configuration', () => expect( @@ -301,7 +301,7 @@ describe('a @use rule', () => { variables: {bar: {text: 'baz', quotes: true}}, }, }).toString(), - ).toBe('@use "foo" with ($bar: "baz");')); + ).toBe('@use "foo" with ($bar: "baz")')); }); describe('with a URL raw', () => { @@ -311,7 +311,7 @@ describe('a @use rule', () => { useUrl: 'foo', raws: {url: {raw: "'foo'", value: 'foo'}}, }).toString(), - ).toBe("@use 'foo';")); + ).toBe("@use 'foo'")); it("that doesn't match", () => expect( @@ -319,7 +319,7 @@ describe('a @use rule', () => { useUrl: 'foo', raws: {url: {raw: "'bar'", value: 'bar'}}, }).toString(), - ).toBe('@use "foo";')); + ).toBe('@use "foo"')); }); describe('with a namespace raw', () => { @@ -329,7 +329,7 @@ describe('a @use rule', () => { useUrl: 'foo', raws: {namespace: {raw: ' as foo', value: 'foo'}}, }).toString(), - ).toBe('@use "foo" as foo;')); + ).toBe('@use "foo" as foo')); it('that matches null', () => expect( @@ -338,7 +338,7 @@ describe('a @use rule', () => { namespace: null, raws: {namespace: {raw: ' as *', value: null}}, }).toString(), - ).toBe('@use "foo" as *;')); + ).toBe('@use "foo" as *')); it("that doesn't match", () => expect( @@ -346,7 +346,7 @@ describe('a @use rule', () => { useUrl: 'foo', raws: {url: {raw: ' as bar', value: 'bar'}}, }).toString(), - ).toBe('@use "foo";')); + ).toBe('@use "foo"')); }); describe('with beforeWith', () => { @@ -359,7 +359,7 @@ describe('a @use rule', () => { }, raws: {beforeWith: '/**/'}, }).toString(), - ).toBe('@use "foo"/**/with ($bar: "baz");')); + ).toBe('@use "foo"/**/with ($bar: "baz")')); it('and no configuration', () => expect( @@ -367,7 +367,7 @@ describe('a @use rule', () => { useUrl: 'foo', raws: {beforeWith: '/**/'}, }).toString(), - ).toBe('@use "foo";')); + ).toBe('@use "foo"')); }); describe('with afterWith', () => { @@ -380,7 +380,7 @@ describe('a @use rule', () => { }, raws: {afterWith: '/**/'}, }).toString(), - ).toBe('@use "foo" with/**/($bar: "baz");')); + ).toBe('@use "foo" with/**/($bar: "baz")')); it('and no configuration', () => expect( @@ -388,7 +388,7 @@ describe('a @use rule', () => { useUrl: 'foo', raws: {afterWith: '/**/'}, }).toString(), - ).toBe('@use "foo";')); + ).toBe('@use "foo"')); }); }); }); diff --git a/pkg/sass-parser/lib/src/statement/variable-declaration.test.ts b/pkg/sass-parser/lib/src/statement/variable-declaration.test.ts index 26bb44391..52d3bd17b 100644 --- a/pkg/sass-parser/lib/src/statement/variable-declaration.test.ts +++ b/pkg/sass-parser/lib/src/statement/variable-declaration.test.ts @@ -280,13 +280,6 @@ describe('a variable declaration', () => { expect(node).toHaveStringExpression('expression', 'baz'); }); - it('assigned a new expression', () => { - const old = node.expression; - node.expression = {text: 'baz'}; - expect(old.parent).toBeUndefined(); - expect(node).toHaveStringExpression('expression', 'baz'); - }); - it('assigned a value', () => { node.value = 'Helvetica, sans-serif'; expect(node).toHaveStringExpression('expression', 'Helvetica, sans-serif'); diff --git a/pkg/sass-parser/lib/src/statement/variable-declaration.ts b/pkg/sass-parser/lib/src/statement/variable-declaration.ts index db71203ec..c1f3d1885 100644 --- a/pkg/sass-parser/lib/src/statement/variable-declaration.ts +++ b/pkg/sass-parser/lib/src/statement/variable-declaration.ts @@ -9,11 +9,13 @@ import {Expression, ExpressionProps} from '../expression'; import {convertExpression} from '../expression/convert'; import {fromProps} from '../expression/from-props'; import {LazySource} from '../lazy-source'; +import {NodeProps} from '../node'; import {RawWithValue} from '../raw-with-value'; import * as sassInternal from '../sass-internal'; import * as utils from '../utils'; import {Statement, StatementWithChildren} from '.'; import {_Declaration} from './declaration-internal'; +import * as sassParser from '../..'; /** * The set of raws supported by {@link VariableDeclaration}. @@ -25,7 +27,7 @@ export interface VariableDeclarationRaws /** * The variable's namespace. * - * This may be different than {@link VariableDeclarationRaws.namespace} if the + * This may be different than {@link VariableDeclaration.namespace} if the * name contains escape codes or underscores. */ namespace?: RawWithValue; @@ -33,7 +35,7 @@ export interface VariableDeclarationRaws /** * The variable's name, not including the `$`. * - * This may be different than {@link VariableDeclarationRaws.variableName} if + * This may be different than {@link VariableDeclaration.variableName} if * the name contains escape codes or underscores. */ variableName?: RawWithValue; @@ -63,12 +65,15 @@ export type VariableDeclarationProps = NodeProps & { variableName: string; guarded?: boolean; global?: boolean; -} & ({expression: Expression | ExpressionProps} | {value: string}); +} & ( + | {expression: Expression | ExpressionProps; value?: never} + | {value: string; expression?: never} + ); /** * A Sass variable declaration. Extends [`postcss.Declaration`]. * - * [`postcss.AtRule`]: https://postcss.org/api/#declaration + * [`postcss.Declaration`]: https://postcss.org/api/#declaration * * @category Statement */ @@ -208,17 +213,11 @@ export class VariableDeclaration } /** @hidden */ - toString(): string { - return ( - this.prop + - (this.raws.between ?? ': ') + - this.expression + - (this.raws.flags?.value?.guarded === this.guarded && - this.raws.flags?.value?.global === this.global - ? this.raws.flags.raw - : (this.guarded ? ' !default' : '') + (this.global ? ' !global' : '')) + - (this.raws.afterValue ?? '') - ); + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); } /** @hidden */ diff --git a/pkg/sass-parser/lib/src/statement/warn-rule.test.ts b/pkg/sass-parser/lib/src/statement/warn-rule.test.ts index a314ada09..471a2cccf 100644 --- a/pkg/sass-parser/lib/src/statement/warn-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/warn-rule.test.ts @@ -107,7 +107,7 @@ describe('a @warn rule', () => { new WarnRule({ warnExpression: {text: 'foo'}, }).toString(), - ).toBe('@warn foo;')); + ).toBe('@warn foo')); it('with afterName', () => expect( @@ -115,7 +115,7 @@ describe('a @warn rule', () => { warnExpression: {text: 'foo'}, raws: {afterName: '/**/'}, }).toString(), - ).toBe('@warn/**/foo;')); + ).toBe('@warn/**/foo')); it('with between', () => expect( @@ -123,7 +123,7 @@ describe('a @warn rule', () => { warnExpression: {text: 'foo'}, raws: {between: '/**/'}, }).toString(), - ).toBe('@warn foo/**/;')); + ).toBe('@warn foo/**/')); }); }); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index a2862ccaa..08cfb68aa 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -30,6 +30,7 @@ import * as postcss from 'postcss'; import {AnyStatement} from './statement'; import {DebugRule} from './statement/debug-rule'; +import {Declaration} from './statement/declaration'; import {EachRule} from './statement/each-rule'; import {ErrorRule} from './statement/error-rule'; import {ForRule} from './statement/for-rule'; @@ -41,6 +42,7 @@ import {ReturnRule} from './statement/return-rule'; import {Rule} from './statement/rule'; import {SassComment} from './statement/sass-comment'; import {UseRule} from './statement/use-rule'; +import {VariableDeclaration} from './statement/variable-declaration'; import {WarnRule} from './statement/warn-rule'; import {WhileRule} from './statement/while-rule'; @@ -83,6 +85,38 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } + decl(node: Declaration, semicolon: boolean): void { + const start = + node.propInterpolation.toString() + + (node.raws.between ?? (node.expression ? ': ' : ':')) + + (node.expression ? node.expression : ''); + + // We can't use Stringifier.block() here because it expects the "between" + // raw to refer to the whitespace immediately before `{`, but for a + // declaration (even one with children) it refers to `: ` instead. + if (node.nodes) { + this.builder(start + (node.raws.afterValue ?? ' ') + '{'); + + let after; + if (node.nodes.length) { + this.body(node); + after = this.raw(node, 'after'); + } else { + after = this.raw(node, 'after', 'emptyBody'); + } + + if (after) this.builder(after); + this.builder('}', node, 'end'); + if (node.raws.ownSemicolon) { + this.builder(node.raws.ownSemicolon, node, 'end'); + } + } else { + this.builder( + start + (node.raws.afterValue ?? '') + (semicolon ? ';' : ''), + ); + } + } + private ['each-rule'](node: EachRule): void { this.sassAtRule(node); } @@ -192,6 +226,25 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } + private ['variable-declaration']( + node: VariableDeclaration, + semicolon: boolean, + ): void { + this.builder( + node.prop + + this.raw(node, 'between', 'colon') + + node.expression + + (node.raws.flags?.value?.guarded === node.guarded && + node.raws.flags?.value?.global === node.global + ? node.raws.flags.raw + : (node.guarded ? ' !default' : '') + + (node.global ? ' !global' : '')) + + (node.raws.afterValue ?? '') + + (semicolon ? ';' : ''), + node, + ); + } + private ['while-rule'](node: WhileRule): void { this.sassAtRule(node); }