diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cb37fb7..64a7e3603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.84.1-dev + +* No user-visible changes. + ## 1.84.0 * Allow newlines in whitespace in the indented syntax. diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index b118e0db2..09549f427 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.4.14-dev + +* Add support for parsing color expressions. + ## 0.4.13 * No user-visible changes. diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 1eb6c1546..bbb34b32f 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -65,6 +65,11 @@ export { BooleanExpressionProps, BooleanExpressionRaws, } from './src/expression/boolean'; +export { + ColorExpression, + ColorExpressionProps, + ColorExpressionRaws, +} from './src/expression/color'; export { NumberExpression, NumberExpressionProps, diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/color.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/color.test.ts.snap new file mode 100644 index 000000000..4a9f5852b --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/color.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a color expression toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@#{#00f}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "color", + "source": <1:4-1:8 in 0>, + "value": { + "alpha": 1, + "channels": [ + 0, + 0, + 255, + ], + "space": "rgb", + }, +} +`; diff --git a/pkg/sass-parser/lib/src/expression/color.test.ts b/pkg/sass-parser/lib/src/expression/color.test.ts new file mode 100644 index 000000000..f0876845c --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/color.test.ts @@ -0,0 +1,246 @@ +// 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 {SassColor} from 'sass'; + +import {ColorExpression} from '../..'; +import * as utils from '../../../test/utils'; + +const blue = new SassColor({space: 'rgb', red: 0, green: 0, blue: 255}); + +describe('a color expression', () => { + let node: ColorExpression; + + describe('with no alpha', () => { + function describeNode( + description: string, + create: () => ColorExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType color', () => expect(node.sassType).toBe('color')); + + it('is a color', () => expect(node.value).toEqual(blue)); + }); + } + + describe('parsed', () => { + describeNode('hex', () => utils.parseExpression('#00f')); + + describeNode('keyword', () => utils.parseExpression('blue')); + }); + + describeNode( + 'constructed manually', + () => new ColorExpression({value: blue}), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({value: blue}), + ); + }); + + describe('with alpha', () => { + function describeNode( + description: string, + create: () => ColorExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType color', () => expect(node.sassType).toBe('color')); + + it('is a color', () => + expect(node.value).toEqual( + new SassColor({ + space: 'rgb', + red: 10, + green: 20, + blue: 30, + alpha: 0.4, + }), + )); + }); + } + + describeNode('parsed', () => utils.parseExpression('#0a141E66')); + + describeNode( + 'constructed manually', + () => + new ColorExpression({ + value: new SassColor({ + space: 'rgb', + red: 10, + green: 20, + blue: 30, + alpha: 0.4, + }), + }), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({ + value: new SassColor({ + space: 'rgb', + red: 10, + green: 20, + blue: 30, + alpha: 0.4, + }), + }), + ); + }); + + describe('throws an error for non-RGB colors', () => { + beforeEach(() => void (node = utils.parseExpression('#123'))); + + it('in the constructor', () => + expect( + () => + new ColorExpression({ + value: new SassColor({ + space: 'hsl', + hue: 180, + saturation: 50, + lightness: 50, + }), + }), + ).toThrow()); + + it('in the property', () => + expect(() => { + node.value = new SassColor({ + space: 'hsl', + hue: 180, + saturation: 50, + lightness: 50, + }); + }).toThrow()); + + it('in clone', () => + expect(() => + node.clone({ + value: new SassColor({ + space: 'hsl', + hue: 180, + saturation: 50, + lightness: 50, + }), + }), + ).toThrow()); + }); + + it('assigned new value', () => { + const node = utils.parseExpression('#123') as ColorExpression; + node.value = new SassColor({ + space: 'rgb', + red: 10, + green: 20, + blue: 30, + alpha: 0.4, + }); + expect(node.value).toEqual( + new SassColor({ + space: 'rgb', + red: 10, + green: 20, + blue: 30, + alpha: 0.4, + }), + ); + }); + + describe('stringifies', () => { + it('without alpha', () => + expect(utils.parseExpression('#abc').toString()).toBe('#aabbcc')); + + it('with alpha', () => + expect(utils.parseExpression('#abcd').toString()).toBe('#aabbccdd')); + + describe('raws', () => { + it('with the same raw value as the expression', () => + expect( + new ColorExpression({ + value: blue, + raws: {value: {raw: 'blue', value: blue}}, + }).toString(), + ).toBe('blue')); + + it('with a different raw value than the expression', () => + expect( + new ColorExpression({ + value: new SassColor({space: 'rgb', red: 10, green: 20, blue: 30}), + raws: {value: {raw: 'blue', value: blue}}, + }).toString(), + ).toBe('#0a141e')); + }); + }); + + describe('clone', () => { + let original: ColorExpression; + + beforeEach(() => { + original = utils.parseExpression('#00f'); + // TODO: remove this once raws are properly parsed. + original.raws.value = {raw: 'blue', value: blue}; + }); + + describe('with no overrides', () => { + let clone: ColorExpression; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('value', () => expect(clone.value).toEqual(blue)); + + it('raws', () => { + expect(clone.raws.value!.raw).toBe('blue'); + expect(clone.raws.value!.value).toEqual(blue); + }); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + it('creates a new self', () => expect(clone).not.toBe(original)); + }); + + describe('overrides', () => { + describe('value', () => { + it('defined', () => + expect( + original.clone({ + value: new SassColor({ + space: 'rgb', + red: 10, + green: 20, + blue: 30, + }), + }).value, + ).toEqual( + new SassColor({space: 'rgb', red: 10, green: 20, blue: 30}), + )); + + it('undefined', () => + expect(original.clone({value: undefined}).value).toEqual(blue)); + }); + + describe('raws', () => { + it('defined', () => + expect( + original.clone({raws: {value: {raw: '#0000FF', value: blue}}}).raws + .value!.raw, + ).toBe('#0000FF')); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws.value!.raw).toBe( + 'blue', + )); + }); + }); + }); + + it('toJSON', () => expect(utils.parseExpression('#00f')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/expression/color.ts b/pkg/sass-parser/lib/src/expression/color.ts new file mode 100644 index 000000000..8105cce1d --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/color.ts @@ -0,0 +1,121 @@ +// 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 * as sass from 'sass'; + +import {LazySource} from '../lazy-source'; +import {NodeProps} from '../node'; +import {RawWithValue} from '../raw-with-value'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Expression} from '.'; + +/** + * The initializer properties for {@link ColorExpression}. + * + * @category Expression + */ +export interface ColorExpressionProps extends NodeProps { + raws?: ColorExpressionRaws; + + /** + * The color value for the expression. This must be in the RGB color space, + * or {@link ColorExpression} will throw an error. + */ + value: sass.SassColor; +} + +/** + * Raws indicating how to precisely serialize a {@link ColorExpression}. + * + * @category Expression + */ +export interface ColorExpressionRaws { + /** + * The raw string representation of the color. + * + * Colors can be represented as named keywords or as hex codes, with or + * without capitalizations and with a varying number of digits. + */ + value?: RawWithValue; +} + +/** + * An expression representing a color literal in Sass. + * + * @category Expression + */ +export class ColorExpression extends Expression { + readonly sassType = 'color' as const; + declare raws: ColorExpressionRaws; + + /** + * The color represented by this expression. This will always be a color in + * the RGB color space. + * + * Throws an error if this is set to a non-RGB color. + */ + get value(): sass.SassColor { + return this._value; + } + set value(value: sass.SassColor) { + if (value.space !== 'rgb') { + throw new Error( + `Can't set ColorExpression.color to ${value}. Only RGB colors can ` + + 'be represented as color literals.', + ); + } + + // TODO - postcss/postcss#1957: Mark this as dirty + this._value = value; + } + private declare _value: sass.SassColor; + + constructor(defaults: ColorExpressionProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ColorExpression); + constructor(defaults?: object, inner?: sassInternal.ColorExpression) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.value = inner.value; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'value']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['value'], inputs); + } + + /** @hidden */ + toString(): string { + return this.raws?.value?.value?.equals(this.value) + ? this.raws.value.raw + : '#' + + (['red', 'green', 'blue'] as const) + .map(name => + Math.round(this.value.channel(name)) + .toString(16) + .padStart(2, '0'), + ) + .join('') + + (this.value.alpha >= 1 + ? '' + : Math.round(this.value.alpha * 255) + .toString(16) + .padStart(2, '0')); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return []; + } +} diff --git a/pkg/sass-parser/lib/src/expression/convert.ts b/pkg/sass-parser/lib/src/expression/convert.ts index 63619898f..a4b405fcd 100644 --- a/pkg/sass-parser/lib/src/expression/convert.ts +++ b/pkg/sass-parser/lib/src/expression/convert.ts @@ -4,11 +4,12 @@ import * as sassInternal from '../sass-internal'; -import {BinaryOperationExpression} from './binary-operation'; -import {StringExpression} from './string'; import {Expression} from '.'; +import {BinaryOperationExpression} from './binary-operation'; import {BooleanExpression} from './boolean'; +import {ColorExpression} from './color'; import {NumberExpression} from './number'; +import {StringExpression} from './string'; /** The visitor to use to convert internal Sass nodes to JS. */ const visitor = sassInternal.createExpressionVisitor({ @@ -16,6 +17,7 @@ const visitor = sassInternal.createExpressionVisitor({ new BinaryOperationExpression(undefined, inner), visitStringExpression: inner => new StringExpression(undefined, inner), visitBooleanExpression: inner => new BooleanExpression(undefined, inner), + visitColorExpression: inner => new ColorExpression(undefined, inner), visitNumberExpression: inner => new NumberExpression(undefined, inner), }); diff --git a/pkg/sass-parser/lib/src/expression/from-props.ts b/pkg/sass-parser/lib/src/expression/from-props.ts index d74450813..963813c89 100644 --- a/pkg/sass-parser/lib/src/expression/from-props.ts +++ b/pkg/sass-parser/lib/src/expression/from-props.ts @@ -2,11 +2,13 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import {BinaryOperationExpression} from './binary-operation'; +import * as sass from 'sass'; import {Expression, ExpressionProps} from '.'; -import {StringExpression} from './string'; +import {BinaryOperationExpression} from './binary-operation'; import {BooleanExpression} from './boolean'; +import {ColorExpression} from './color'; import {NumberExpression} from './number'; +import {StringExpression} from './string'; /** Constructs an expression from {@link ExpressionProps}. */ export function fromProps(props: ExpressionProps): Expression { @@ -15,7 +17,10 @@ export function fromProps(props: ExpressionProps): Expression { if ('value' in props) { if (typeof props.value === 'boolean') return new BooleanExpression(props); if (typeof props.value === 'number') return new NumberExpression(props); + if (props.value instanceof sass.SassColor) { + return new ColorExpression(props); + } } - throw new Error(`Unknown node type: ${props}`); + throw new Error(`Unknown node type, keys: ${Object.keys(props)}`); } diff --git a/pkg/sass-parser/lib/src/expression/index.ts b/pkg/sass-parser/lib/src/expression/index.ts index ac1d37671..3d2547e4b 100644 --- a/pkg/sass-parser/lib/src/expression/index.ts +++ b/pkg/sass-parser/lib/src/expression/index.ts @@ -8,6 +8,7 @@ import type { BinaryOperationExpressionProps, } from './binary-operation'; import {BooleanExpression, BooleanExpressionProps} from './boolean'; +import {ColorExpression, ColorExpressionProps} from './color'; import {NumberExpression, NumberExpressionProps} from './number'; import type {StringExpression, StringExpressionProps} from './string'; @@ -18,9 +19,10 @@ import type {StringExpression, StringExpressionProps} from './string'; */ export type AnyExpression = | BinaryOperationExpression - | StringExpression | BooleanExpression - | NumberExpression; + | ColorExpression + | NumberExpression + | StringExpression; /** * Sass expression types. @@ -29,9 +31,10 @@ export type AnyExpression = */ export type ExpressionType = | 'binary-operation' - | 'string' | 'boolean' - | 'number'; + | 'color' + | 'number' + | 'string'; /** * The union type of all properties that can be used to construct Sass @@ -41,9 +44,10 @@ export type ExpressionType = */ export type ExpressionProps = | BinaryOperationExpressionProps - | StringExpressionProps | BooleanExpressionProps - | NumberExpressionProps; + | ColorExpressionProps + | NumberExpressionProps + | StringExpressionProps; /** * The superclass of Sass expression nodes. diff --git a/pkg/sass-parser/lib/src/expression/number.test.ts b/pkg/sass-parser/lib/src/expression/number.test.ts index 3e95a2061..94045e19b 100644 --- a/pkg/sass-parser/lib/src/expression/number.test.ts +++ b/pkg/sass-parser/lib/src/expression/number.test.ts @@ -163,7 +163,7 @@ describe('a number expression', () => { describe('overrides', () => { describe('value', () => { it('defined', () => - expect(original.clone({value: 123}).value).toBe(123)); + expect(original.clone({value: 321}).value).toBe(321)); it('undefined', () => expect(original.clone({value: undefined}).value).toBe(123)); diff --git a/pkg/sass-parser/lib/src/expression/number.ts b/pkg/sass-parser/lib/src/expression/number.ts index a6d35546f..e72470707 100644 --- a/pkg/sass-parser/lib/src/expression/number.ts +++ b/pkg/sass-parser/lib/src/expression/number.ts @@ -6,6 +6,7 @@ import * as postcss from 'postcss'; import {LazySource} from '../lazy-source'; import {NodeProps} from '../node'; +import {RawWithValue} from '../raw-with-value'; import type * as sassInternal from '../sass-internal'; import * as utils from '../utils'; import {Expression} from '.'; @@ -34,8 +35,7 @@ export interface NumberExpressionRaws { * use scientific notation. For example, the following number representations * have the same value: `1e3`, `1000`, `01000.0`. */ - // TODO: Replace with RawWithValue when #2389 lands. - value?: {raw: string; value: number}; + value?: RawWithValue; } /** diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 22e71e6d8..131d74e12 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -2,8 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import * as sass from 'sass'; import * as postcss from 'postcss'; +import * as sass from 'sass'; import type * as binaryOperation from './expression/binary-operation'; @@ -334,6 +334,10 @@ declare namespace SassInternal { readonly value: boolean; } + class ColorExpression extends Expression { + readonly value: sass.SassColor; + } + class NumberExpression extends Expression { readonly value: number; readonly unit: string; @@ -388,6 +392,7 @@ export type Expression = SassInternal.Expression; export type BinaryOperationExpression = SassInternal.BinaryOperationExpression; export type StringExpression = SassInternal.StringExpression; export type BooleanExpression = SassInternal.BooleanExpression; +export type ColorExpression = SassInternal.ColorExpression; export type NumberExpression = SassInternal.NumberExpression; export interface StatementVisitorObject { @@ -422,6 +427,7 @@ export interface ExpressionVisitorObject { visitBinaryOperationExpression(node: BinaryOperationExpression): T; visitStringExpression(node: StringExpression): T; visitBooleanExpression(node: BooleanExpression): T; + visitColorExpression(node: ColorExpression): T; visitNumberExpression(node: NumberExpression): T; } diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts index f73eab798..6c017b721 100644 --- a/pkg/sass-parser/lib/src/utils.ts +++ b/pkg/sass-parser/lib/src/utils.ts @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. import * as postcss from 'postcss'; +import * as sass from 'sass'; import {Node} from './node'; @@ -108,7 +109,13 @@ function maybeClone(value: T): T { if (typeof value !== 'object' || value === null) return value; // The only records we care about are raws, which only contain primitives and // arrays of primitives, so structued cloning is safe. - if (value.constructor === Object) return structuredClone(value); + if (value.constructor === Object) { + const clone: Record = {}; + for (const [key, child] of Object.entries(value)) { + clone[key] = maybeClone(child); + } + return clone as T; + } if (value instanceof postcss.Node) return value.clone() as T; return value; } @@ -187,6 +194,12 @@ function toJsonField( } else { return (value as {toJSON: (field: string) => object}).toJSON(field); } + } else if (value instanceof sass.SassColor) { + return { + space: value.space, + channels: [...value.channelsOrNull], + alpha: value.isChannelMissing('alpha') ? null : value.alpha, + }; } else { return value; } diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json index cf6a374ca..6c566b5f7 100644 --- a/pkg/sass-parser/package.json +++ b/pkg/sass-parser/package.json @@ -1,6 +1,6 @@ { "name": "sass-parser", - "version": "0.4.13", + "version": "0.4.14-dev", "description": "A PostCSS-compatible wrapper of the official Sass parser", "repository": "sass/sass", "author": "Google Inc.", diff --git a/pkg/sass-parser/test/setup.ts b/pkg/sass-parser/test/setup.ts index cbafd0682..d48e202b8 100644 --- a/pkg/sass-parser/test/setup.ts +++ b/pkg/sass-parser/test/setup.ts @@ -8,6 +8,7 @@ import * as postcss from 'postcss'; // Unclear why eslint considers this extraneous // eslint-disable-next-line n/no-extraneous-import import type * as pretty from 'pretty-format'; +import * as sass from 'sass'; import 'jest-extended'; import {Interpolation, StringExpression} from '../lib'; @@ -109,6 +110,16 @@ function toHaveInterpolation( expect.extend({toHaveInterpolation}); +function areColorsEqual(color1: unknown, color2: unknown): boolean | undefined { + if (color1 instanceof sass.SassColor && color2 instanceof sass.SassColor) { + return color1.equals(color2); + } else { + return undefined; + } +} + +expect.addEqualityTesters([areColorsEqual]); + function toHaveStringExpression( this: MatcherContext, actual: unknown, diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index fc9d28bc6..902c89a3b 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,7 @@ +## 15.1.1-dev + +* No user-visible changes. + ## 15.1.0 * No user-visible changes. diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index f5738b2a3..91ffa6df7 100644 --- a/pkg/sass_api/pubspec.yaml +++ b/pkg/sass_api/pubspec.yaml @@ -2,7 +2,7 @@ name: sass_api # Note: Every time we add a new Sass AST node, we need to bump the *major* # version because it's a breaking change for anyone who's implementing the # visitor interface(s). -version: 15.1.0 +version: 15.1.1-dev description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass @@ -10,7 +10,7 @@ environment: sdk: ">=3.3.0 <4.0.0" dependencies: - sass: 1.84.0 + sass: 1.84.1 dev_dependencies: dartdoc: ^8.0.14 diff --git a/pubspec.yaml b/pubspec.yaml index 85646fb6b..8fec6a1d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.84.0 +version: 1.84.1-dev description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass