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