diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6796d6b..539705f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.80.4 + +* No user-visible changes. + ## 1.80.3 * Fix a bug where `@import url("...")` would crash in plain CSS files. diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index 968d6d72b..eae5707f7 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.4.1 + +* Add `BooleanExpression` and `NumberExpression`. + ## 0.4.0 * **Breaking change:** Warnings are no longer emitted during parsing, so the diff --git a/pkg/sass-parser/README.md b/pkg/sass-parser/README.md index 89908a492..bfb2b3520 100644 --- a/pkg/sass-parser/README.md +++ b/pkg/sass-parser/README.md @@ -257,3 +257,17 @@ There are a few cases where an operation that's valid in PostCSS won't work with * Trying to add child nodes to a Sass statement that doesn't support children like `@use` or `@error` is not supported. + +## Contributing + +Before sending out a pull request, please run the following commands from the +`pkg/sass-parser` directory: + +* `npm run check` - Runs `eslint`, and then tries to compile the package with + `tsc`. + +* `npm run test` - Runs all the tests in the package. + +Note: You should run `dart run grinder before-test` from the `dart-sass` +directory beforehand to ensure you're running `sass-parser` against the latest +version of `dart-sass` JavaScript API. diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 5746f98e4..11f9e3bd1 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -3,7 +3,6 @@ // https://opensource.org/licenses/MIT. import * as postcss from 'postcss'; -import * as sassApi from 'sass'; import {Root} from './src/statement/root'; import * as sassInternal from './src/sass-internal'; @@ -27,6 +26,16 @@ export { StringExpressionProps, StringExpressionRaws, } from './src/expression/string'; +export { + BooleanExpression, + BooleanExpressionProps, + BooleanExpressionRaws, +} from './src/expression/boolean'; +export { + NumberExpression, + NumberExpressionProps, + NumberExpressionRaws, +} from './src/expression/number'; export { Interpolation, InterpolationProps, diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/boolean.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/boolean.test.ts.snap new file mode 100644 index 000000000..1b68fedcf --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/boolean.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a boolean expression toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@#{true}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "boolean", + "source": <1:4-1:8 in 0>, + "value": true, +} +`; diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/number.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/number.test.ts.snap new file mode 100644 index 000000000..6af882031 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/number.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a number expression toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@#{123%}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "number", + "source": <1:4-1:8 in 0>, + "unit": "%", + "value": 123, +} +`; diff --git a/pkg/sass-parser/lib/src/expression/boolean.test.ts b/pkg/sass-parser/lib/src/expression/boolean.test.ts new file mode 100644 index 000000000..ca9f64f1b --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/boolean.test.ts @@ -0,0 +1,122 @@ +// 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 {BooleanExpression} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a boolean expression', () => { + let node: BooleanExpression; + + describe('true', () => { + function describeNode( + description: string, + create: () => BooleanExpression + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType boolean', () => expect(node.sassType).toBe('boolean')); + + it('is true', () => expect(node.value).toBe(true)); + }); + } + + describeNode('parsed', () => utils.parseExpression('true')); + + describeNode( + 'constructed manually', + () => new BooleanExpression({value: true}) + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({value: true}) + ); + }); + + describe('false', () => { + function describeNode( + description: string, + create: () => BooleanExpression + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType boolean', () => expect(node.sassType).toBe('boolean')); + + it('is false', () => expect(node.value).toBe(false)); + }); + } + + describeNode('parsed', () => utils.parseExpression('false')); + + describeNode( + 'constructed manually', + () => new BooleanExpression({value: false}) + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({value: false}) + ); + }); + + it('assigned new value', () => { + node = utils.parseExpression('true'); + node.value = false; + expect(node.value).toBe(false); + }); + + describe('stringifies', () => { + it('true', () => { + expect(utils.parseExpression('true').toString()).toBe('true'); + }); + + it('false', () => { + expect(utils.parseExpression('false').toString()).toBe('false'); + }); + }); + + describe('clone', () => { + let original: BooleanExpression; + + beforeEach(() => { + original = utils.parseExpression('true'); + }); + + describe('with no overrides', () => { + let clone: BooleanExpression; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('value', () => expect(clone.value).toBe(true)); + + it('raws', () => expect(clone.raws).toEqual({})); + + 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: false}).value).toBe(false)); + + it('undefined', () => + expect(original.clone({value: undefined}).value).toBe(true)); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + it('toJSON', () => expect(utils.parseExpression('true')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/expression/boolean.ts b/pkg/sass-parser/lib/src/expression/boolean.ts new file mode 100644 index 000000000..a75c567ed --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/boolean.ts @@ -0,0 +1,82 @@ +// 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 {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Expression} from '.'; + +/** + * The initializer properties for {@link BooleanExpression}. + * + * @category Expression + */ +export interface BooleanExpressionProps { + value: boolean; + raws?: BooleanExpressionRaws; +} + +/** + * Raws indicating how to precisely serialize a {@link BooleanExpression}. + * + * @category Expression + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for a boolean expression yet. +export interface BooleanExpressionRaws {} + +/** + * An expression representing a boolean literal in Sass. + * + * @category Expression + */ +export class BooleanExpression extends Expression { + readonly sassType = 'boolean' as const; + declare raws: BooleanExpressionRaws; + + /** The boolean value of this expression. */ + get value(): boolean { + return this._value; + } + set value(value: boolean) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._value = value; + } + private _value!: boolean; + + constructor(defaults: BooleanExpressionProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.BooleanExpression); + constructor(defaults?: object, inner?: sassInternal.BooleanExpression) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.value = inner.value; + } else { + this.value ??= false; + } + } + + 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.value ? 'true' : 'false'; + } + + /** @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 792a74b11..52cbb2363 100644 --- a/pkg/sass-parser/lib/src/expression/convert.ts +++ b/pkg/sass-parser/lib/src/expression/convert.ts @@ -7,12 +7,16 @@ import * as sassInternal from '../sass-internal'; import {BinaryOperationExpression} from './binary-operation'; import {StringExpression} from './string'; import {Expression} from '.'; +import {BooleanExpression} from './boolean'; +import {NumberExpression} from './number'; /** The visitor to use to convert internal Sass nodes to JS. */ const visitor = sassInternal.createExpressionVisitor({ visitBinaryOperationExpression: inner => new BinaryOperationExpression(undefined, inner), visitStringExpression: inner => new StringExpression(undefined, inner), + visitBooleanExpression: inner => new BooleanExpression(undefined, inner), + visitNumberExpression: inner => new NumberExpression(undefined, inner), }); /** Converts an internal expression AST node into an external one. */ diff --git a/pkg/sass-parser/lib/src/expression/from-props.ts b/pkg/sass-parser/lib/src/expression/from-props.ts index 030684e52..d74450813 100644 --- a/pkg/sass-parser/lib/src/expression/from-props.ts +++ b/pkg/sass-parser/lib/src/expression/from-props.ts @@ -5,10 +5,17 @@ import {BinaryOperationExpression} from './binary-operation'; import {Expression, ExpressionProps} from '.'; import {StringExpression} from './string'; +import {BooleanExpression} from './boolean'; +import {NumberExpression} from './number'; /** Constructs an expression from {@link ExpressionProps}. */ export function fromProps(props: ExpressionProps): Expression { if ('text' in props) return new StringExpression(props); if ('left' in props) return new BinaryOperationExpression(props); + if ('value' in props) { + if (typeof props.value === 'boolean') return new BooleanExpression(props); + if (typeof props.value === 'number') return new NumberExpression(props); + } + throw new Error(`Unknown node type: ${props}`); } diff --git a/pkg/sass-parser/lib/src/expression/index.ts b/pkg/sass-parser/lib/src/expression/index.ts index a5f599133..ac1d37671 100644 --- a/pkg/sass-parser/lib/src/expression/index.ts +++ b/pkg/sass-parser/lib/src/expression/index.ts @@ -7,6 +7,8 @@ import type { BinaryOperationExpression, BinaryOperationExpressionProps, } from './binary-operation'; +import {BooleanExpression, BooleanExpressionProps} from './boolean'; +import {NumberExpression, NumberExpressionProps} from './number'; import type {StringExpression, StringExpressionProps} from './string'; /** @@ -14,14 +16,22 @@ import type {StringExpression, StringExpressionProps} from './string'; * * @category Expression */ -export type AnyExpression = BinaryOperationExpression | StringExpression; +export type AnyExpression = + | BinaryOperationExpression + | StringExpression + | BooleanExpression + | NumberExpression; /** * Sass expression types. * * @category Expression */ -export type ExpressionType = 'binary-operation' | 'string'; +export type ExpressionType = + | 'binary-operation' + | 'string' + | 'boolean' + | 'number'; /** * The union type of all properties that can be used to construct Sass @@ -31,7 +41,9 @@ export type ExpressionType = 'binary-operation' | 'string'; */ export type ExpressionProps = | BinaryOperationExpressionProps - | StringExpressionProps; + | StringExpressionProps + | BooleanExpressionProps + | NumberExpressionProps; /** * 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 new file mode 100644 index 000000000..7a3fe7671 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/number.test.ts @@ -0,0 +1,197 @@ +// 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 {NumberExpression} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a number expression', () => { + let node: NumberExpression; + + describe('unitless', () => { + function describeNode( + description: string, + create: () => NumberExpression + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType number', () => expect(node.sassType).toBe('number')); + + it('is a number', () => expect(node.value).toBe(123)); + + it('has no unit', () => expect(node.unit).toBeNull()); + }); + } + + describeNode('parsed', () => utils.parseExpression('123')); + + describeNode( + 'constructed manually', + () => new NumberExpression({value: 123}) + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({value: 123}) + ); + }); + + describe('with a unit', () => { + function describeNode( + description: string, + create: () => NumberExpression + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType number', () => expect(node.sassType).toBe('number')); + + it('is a number', () => expect(node.value).toBe(123)); + + it('has a unit', () => expect(node.unit).toBe('px')); + }); + } + + describeNode('parsed', () => utils.parseExpression('123px')); + + describeNode( + 'constructed manually', + () => + new NumberExpression({ + value: 123, + unit: 'px', + }) + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({ + value: 123, + unit: 'px', + }) + ); + }); + + describe('floating-point number', () => { + describe('unitless', () => { + beforeEach(() => void (node = utils.parseExpression('3.14'))); + + it('value', () => expect(node.value).toBe(3.14)); + + it('unit', () => expect(node.unit).toBeNull()); + }); + + describe('with a unit', () => { + beforeEach(() => void (node = utils.parseExpression('1.618px'))); + + it('value', () => expect(node.value).toBe(1.618)); + + it('unit', () => expect(node.unit).toBe('px')); + }); + }); + + describe('assigned new', () => { + beforeEach(() => void (node = utils.parseExpression('123'))); + + it('value', () => { + node.value = 456; + expect(node.value).toBe(456); + }); + + it('unit', () => { + node.unit = 'px'; + expect(node.unit).toBe('px'); + }); + }); + + describe('stringifies', () => { + it('unitless', () => + expect(utils.parseExpression('123').toString()).toBe('123')); + + it('with a unit', () => + expect(utils.parseExpression('123px').toString()).toBe('123px')); + + it('floating-point number', () => + expect(utils.parseExpression('3.14').toString()).toBe('3.14')); + + describe('raws', () => { + it('with the same raw value as the expression', () => + expect( + new NumberExpression({ + value: 123, + raws: {value: {raw: 'hello', value: 123}}, + }).toString() + ).toBe('hello')); + + it('with a different raw value than the expression', () => + expect( + new NumberExpression({ + value: 123, + raws: {value: {raw: 'hello', value: 234}}, + }).toString() + ).toBe('123')); + }); + }); + + describe('clone', () => { + let original: NumberExpression; + + beforeEach(() => { + original = utils.parseExpression('123'); + // TODO: remove this once raws are properly parsed. + original.raws.value = {raw: '0123.0', value: 123}; + }); + + describe('with no overrides', () => { + let clone: NumberExpression; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('value', () => expect(clone.value).toBe(123)); + + it('unit', () => expect(clone.unit).toBeNull()); + + it('raws', () => + expect(clone.raws).toEqual({value: {raw: '0123.0', value: 123}})); + + 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: 123}).value).toBe(123)); + + it('undefined', () => + expect(original.clone({value: undefined}).value).toBe(123)); + }); + + describe('unit', () => { + it('defined', () => + expect(original.clone({unit: 'px'}).unit).toBe('px')); + + it('undefined', () => + expect(original.clone({unit: undefined}).unit).toBeNull()); + }); + + describe('raws', () => { + it('defined', () => + expect( + original.clone({raws: {value: {raw: '1e3', value: 1e3}}}).raws + ).toEqual({ + value: {raw: '1e3', value: 1e3}, + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + value: {raw: '0123.0', value: 123}, + })); + }); + }); + }); + + it('toJSON', () => expect(utils.parseExpression('123%')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/expression/number.ts b/pkg/sass-parser/lib/src/expression/number.ts new file mode 100644 index 000000000..0a5efbebb --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/number.ts @@ -0,0 +1,112 @@ +// 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 {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Expression} from '.'; + +/** + * The initializer properties for {@link NumberExpression}. + * + * @category Expression + */ +export interface NumberExpressionProps { + value: number; + unit?: string; + raws?: NumberExpressionRaws; +} + +/** + * Raws indicating how to precisely serialize a {@link NumberExpression}. + * + * @category Expression + */ +export interface NumberExpressionRaws { + /** + * The raw string representation of the number. + * + * Numbers can be represented with or without leading and trailing zeroes, and + * 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}; +} + +/** + * An expression representing a number literal in Sass. + * + * @category Expression + */ +export class NumberExpression extends Expression { + readonly sassType = 'number' as const; + declare raws: NumberExpressionRaws; + + /** The numeric value of this expression. */ + get value(): number { + return this._value; + } + set value(value: number) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._value = value; + } + private _value!: number; + + /** The denominator units of this number. */ + get unit(): string | null { + return this._unit; + } + set unit(unit: string | null) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._unit = unit; + } + private _unit!: string | null; + + /** Whether the number is unitless. */ + isUnitless(): boolean { + return this.unit === null; + } + + constructor(defaults: NumberExpressionProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.NumberExpression); + constructor(defaults?: object, inner?: sassInternal.NumberExpression) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.value = inner.value; + this.unit = inner.unit; + } else { + this.value ??= 0; + this.unit ??= null; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'value', 'unit']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['value', 'unit'], inputs); + } + + /** @hidden */ + toString(): string { + if (this.raws?.value?.value === this.value) { + return this.raws.value.raw + (this.unit ?? ''); + } + return this.value + (this.unit ?? ''); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return []; + } +} diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 3ae2064ba..b0b42c1a5 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -186,6 +186,15 @@ declare namespace SassInternal { readonly text: Interpolation; readonly hasQuotes: boolean; } + + class BooleanExpression extends Expression { + readonly value: boolean; + } + + class NumberExpression extends Expression { + readonly value: number; + readonly unit: string; + } } const sassInternal = ( @@ -213,6 +222,8 @@ export type Interpolation = SassInternal.Interpolation; export type Expression = SassInternal.Expression; export type BinaryOperationExpression = SassInternal.BinaryOperationExpression; export type StringExpression = SassInternal.StringExpression; +export type BooleanExpression = SassInternal.BooleanExpression; +export type NumberExpression = SassInternal.NumberExpression; export interface StatementVisitorObject { visitAtRootRule(node: AtRootRule): T; @@ -232,6 +243,8 @@ export interface StatementVisitorObject { export interface ExpressionVisitorObject { visitBinaryOperationExpression(node: BinaryOperationExpression): T; visitStringExpression(node: StringExpression): T; + visitBooleanExpression(node: BooleanExpression): T; + visitNumberExpression(node: NumberExpression): T; } export const parse = sassInternal.parse; diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json index f3c720e27..878a800bb 100644 --- a/pkg/sass-parser/package.json +++ b/pkg/sass-parser/package.json @@ -1,6 +1,6 @@ { "name": "sass-parser", - "version": "0.4.0", + "version": "0.4.1-dev", "description": "A PostCSS-compatible wrapper of the official Sass parser", "repository": "sass/sass", "author": "Google Inc.", diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 0621a0d79..e5f3333ab 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,7 @@ +## 14.0.1 + +* No user-visible changes. + ## 14.0.0 * **Breaking change:** Warnings are no longer emitted during parsing, so the diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index 653bdb91f..eaeb53b09 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: 14.0.0 +version: 14.0.1-dev description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass @@ -10,7 +10,7 @@ environment: sdk: ">=3.0.0 <4.0.0" dependencies: - sass: 1.80.3 + sass: 1.80.4-dev dev_dependencies: dartdoc: ^8.0.14 diff --git a/pubspec.yaml b/pubspec.yaml index d3ed7ffac..a3726492f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.80.3 +version: 1.80.4-dev description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass