Skip to content

Commit

Permalink
Expose calculations in JS API (#1988)
Browse files Browse the repository at this point in the history
Co-authored-by: Jonny Gerig Meyer <jonny@oddbird.net>
Co-authored-by: Natalie Weizenbaum <nweiz@google.com>
  • Loading branch information
3 people committed Jul 19, 2023
1 parent fe7f9a1 commit ca2be2a
Show file tree
Hide file tree
Showing 21 changed files with 302 additions and 11 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@

### JavaScript API

* Add a new `SassCalculation` type that represents the calculation objects added
in Dart Sass 1.40.0.

* Add `Value.assertCalculation()`, which returns the value if it's a
`SassCalculation` and throws an error otherwise.

* Produce a better error message when an environment that supports some Node.js
APIs loads the browser entrypoint but attempts to access the filesystem.

Expand Down
3 changes: 3 additions & 0 deletions lib/src/node.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ void main() {
exports.Value = valueClass;
exports.SassBoolean = booleanClass;
exports.SassArgumentList = argumentListClass;
exports.SassCalculation = calculationClass;
exports.CalculationOperation = calculationOperationClass;
exports.CalculationInterpolation = calculationInterpolationClass;
exports.SassColor = colorClass;
exports.SassFunction = functionClass;
exports.SassList = listClass;
Expand Down
38 changes: 36 additions & 2 deletions lib/src/node/compile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,40 @@ Importer _parseImporter(Object? importer) {
}
}

/// Implements the simplification algorithm for custom function return `Value`s.
/// {@link https://github.com/sass/sass/blob/main/spec/types/calculation.md#simplifying-a-calculationvalue}
Value _simplifyValue(Value value) => switch (value) {
SassCalculation() => switch ((
// Match against...
value.name, // ...the calculation name
value.arguments // ...and simplified arguments
.map(_simplifyCalcArg)
.toList()
)) {
('calc', [var first]) => first as Value,
('calc', _) =>
throw ArgumentError('calc() requires exactly one argument.'),
('clamp', [var min, var value, var max]) =>
SassCalculation.clamp(min, value, max),
('clamp', _) =>
throw ArgumentError('clamp() requires exactly 3 arguments.'),
('min', var args) => SassCalculation.min(args),
('max', var args) => SassCalculation.max(args),
(var name, _) => throw ArgumentError(
'"$name" is not a recognized calculation type.'),
},
_ => value,
};

/// Handles simplifying calculation arguments, which are not guaranteed to be
/// Value instances.
Object _simplifyCalcArg(Object value) => switch (value) {
SassCalculation() => _simplifyValue(value),
CalculationOperation() => SassCalculation.operate(value.operator,
_simplifyCalcArg(value.left), _simplifyCalcArg(value.right)),
_ => value,
};

/// Parses `functions` from [record] into a list of [Callable]s or
/// [AsyncCallable]s.
///
Expand All @@ -239,7 +273,7 @@ List<AsyncCallable> _parseFunctions(Object? functions, {bool asynch = false}) {
late Callable callable;
callable = Callable.fromSignature(signature, (arguments) {
var result = (callback as Function)(toJSArray(arguments));
if (result is Value) return result;
if (result is Value) return _simplifyValue(result);
if (isPromise(result)) {
throw 'Invalid return value for custom function '
'"${callable.name}":\n'
Expand All @@ -259,7 +293,7 @@ List<AsyncCallable> _parseFunctions(Object? functions, {bool asynch = false}) {
result = await promiseToFuture<Object>(result as Promise);
}

if (result is Value) return result;
if (result is Value) return _simplifyValue(result);
throw 'Invalid return value for custom function '
'"${callable.name}": $result is not a sass.Value.';
});
Expand Down
3 changes: 3 additions & 0 deletions lib/src/node/exports.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class Exports {
// Value APIs
external set Value(JSClass function);
external set SassArgumentList(JSClass function);
external set SassCalculation(JSClass function);
external set CalculationOperation(JSClass function);
external set CalculationInterpolation(JSClass function);
external set SassBoolean(JSClass function);
external set SassColor(JSClass function);
external set SassFunction(JSClass function);
Expand Down
10 changes: 10 additions & 0 deletions lib/src/node/reflection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ extension JSClassExtension on JSClass {
allowInteropCaptureThis((Object self, _, __, [___]) => inspect(self)));
}

/// Defines a static method with the given [name] and [body].
void defineStaticMethod(String name, Function body) {
setProperty(this, name, allowInteropNamed(name, body));
}

/// A shorthand for calling [defineStaticMethod] multiple times.
void defineStaticMethods(Map<String, Function> methods) {
methods.forEach(defineStaticMethod);
}

/// Defines a method with the given [name] and [body].
///
/// The [body] should take an initial `self` parameter, representing the
Expand Down
3 changes: 3 additions & 0 deletions lib/src/node/value.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'reflection.dart';

export 'value/argument_list.dart';
export 'value/boolean.dart';
export 'value/calculation.dart';
export 'value/color.dart';
export 'value/function.dart';
export 'value/list.dart';
Expand All @@ -36,6 +37,8 @@ final JSClass valueClass = () {
'get': (Value self, num index) =>
index < 1 && index >= -1 ? self : undefined,
'assertBoolean': (Value self, [String? name]) => self.assertBoolean(name),
'assertCalculation': (Value self, [String? name]) =>
self.assertCalculation(name),
'assertColor': (Value self, [String? name]) => self.assertColor(name),
'assertFunction': (Value self, [String? name]) => self.assertFunction(name),
'assertMap': (Value self, [String? name]) => self.assertMap(name),
Expand Down
133 changes: 133 additions & 0 deletions lib/src/node/value/calculation.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright 2023 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 'package:collection/collection.dart';
import 'package:node_interop/js.dart';
import 'package:sass/src/node/immutable.dart';
import 'package:sass/src/node/utils.dart';

import '../../value.dart';
import '../reflection.dart';

/// Check that [arg] is a valid argument to a calculation function.
void _assertCalculationValue(Object arg) => switch (arg) {
SassNumber() ||
SassString(hasQuotes: false) ||
SassCalculation() ||
CalculationOperation() ||
CalculationInterpolation() =>
null,
_ => jsThrow(JsError(
'Argument `$arg` must be one of SassNumber, unquoted SassString, '
'SassCalculation, CalculationOperation, CalculationInterpolation')),
};

/// Check that [arg] is an unquoted string or interpolation.
bool _isValidClampArg(Object? arg) => switch (arg) {
CalculationInterpolation() || SassString(hasQuotes: false) => true,
_ => false,
};

/// The JavaScript `SassCalculation` class.
final JSClass calculationClass = () {
var jsClass =
createJSClass('sass.SassCalculation', (Object self, [Object? _]) {
jsThrow(JsError("new sass.SassCalculation() isn't allowed"));
});

jsClass.defineStaticMethods({
'calc': (Object argument) {
_assertCalculationValue(argument);
return SassCalculation.unsimplified('calc', [argument]);
},
'min': (Object arguments) {
var argList = jsToDartList(arguments).cast<Object>();
argList.forEach(_assertCalculationValue);
return SassCalculation.unsimplified('min', argList);
},
'max': (Object arguments) {
var argList = jsToDartList(arguments).cast<Object>();
argList.forEach(_assertCalculationValue);
return SassCalculation.unsimplified('max', argList);
},
'clamp': (Object min, [Object? value, Object? max]) {
if ((value == null && !_isValidClampArg(min)) ||
(max == null && ![min, value].any(_isValidClampArg))) {
jsThrow(JsError('Expected at least one SassString or '
'CalculationInterpolation in `${[
min,
value,
max
].whereNotNull()}`'));
}
[min, value, max].whereNotNull().forEach(_assertCalculationValue);
return SassCalculation.unsimplified(
'clamp', [min, value, max].whereNotNull());
}
});

jsClass.defineMethods({
'assertCalculation': (SassCalculation self, [String? name]) => self,
});

jsClass.defineGetters({
// The `name` getter is included by default by `createJSClass`
'arguments': (SassCalculation self) => ImmutableList(self.arguments),
});

getJSClass(SassCalculation.unsimplified('calc', [SassNumber(1)]))
.injectSuperclass(jsClass);
return jsClass;
}();

/// The JavaScript `CalculationOperation` class.
final JSClass calculationOperationClass = () {
var jsClass = createJSClass('sass.CalculationOperation',
(Object self, String strOperator, Object left, Object right) {
var operator = CalculationOperator.values
.firstWhereOrNull((value) => value.operator == strOperator);
if (operator == null) {
jsThrow(JsError('Invalid operator: $strOperator'));
}
_assertCalculationValue(left);
_assertCalculationValue(right);
return SassCalculation.operateInternal(operator, left, right,
inMinMax: false, simplify: false);
});

jsClass.defineMethods({
'equals': (CalculationOperation self, Object other) => self == other,
'hashCode': (CalculationOperation self) => self.hashCode,
});

jsClass.defineGetters({
'operator': (CalculationOperation self) => self.operator.operator,
'left': (CalculationOperation self) => self.left,
'right': (CalculationOperation self) => self.right,
});

getJSClass(SassCalculation.operateInternal(
CalculationOperator.plus, SassNumber(1), SassNumber(1),
inMinMax: false, simplify: false))
.injectSuperclass(jsClass);
return jsClass;
}();

/// The JavaScript `CalculationInterpolation` class.
final JSClass calculationInterpolationClass = () {
var jsClass = createJSClass('sass.CalculationInterpolation',
(Object self, String value) => CalculationInterpolation(value));

jsClass.defineMethods({
'equals': (CalculationInterpolation self, Object other) => self == other,
'hashCode': (CalculationInterpolation self) => self.hashCode,
});

jsClass.defineGetters({
'value': (CalculationInterpolation self) => self.value,
});

getJSClass(CalculationInterpolation('')).injectSuperclass(jsClass);
return jsClass;
}();
22 changes: 16 additions & 6 deletions lib/src/value/calculation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -328,22 +328,28 @@ class SassCalculation extends Value {
/// {@category Value}
@sealed
class CalculationOperation {
/// We use a getters to allow overriding the logic in the JS API
/// implementation.
/// The operator.
final CalculationOperator operator;
CalculationOperator get operator => _operator;
final CalculationOperator _operator;

/// The left-hand operand.
///
/// This is either a [SassNumber], a [SassCalculation], an unquoted
/// [SassString], a [CalculationOperation], or a [CalculationInterpolation].
final Object left;
Object get left => _left;
final Object _left;

/// The right-hand operand.
///
/// This is either a [SassNumber], a [SassCalculation], an unquoted
/// [SassString], a [CalculationOperation], or a [CalculationInterpolation].
final Object right;
Object get right => _right;
final Object _right;

CalculationOperation._(this.operator, this.left, this.right);
CalculationOperation._(this._operator, this._left, this._right);

bool operator ==(Object other) =>
other is CalculationOperation &&
Expand Down Expand Up @@ -403,9 +409,13 @@ enum CalculationOperator {
/// {@category Value}
@sealed
class CalculationInterpolation {
final String value;
/// We use a getters to allow overriding the logic in the JS API
/// implementation.
String get value => _value;
final String _value;

CalculationInterpolation(this.value);
CalculationInterpolation(this._value);

bool operator ==(Object other) =>
other is CalculationInterpolation && value == other.value;
Expand Down
4 changes: 4 additions & 0 deletions pkg/sass_api/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 7.2.0

* No user-visible changes.

## 7.1.6

* No user-visible changes.
Expand Down
4 changes: 2 additions & 2 deletions pkg/sass_api/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ 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: 7.1.6
version: 7.2.0
description: Additional APIs for Dart Sass.
homepage: https://github.com/sass/dart-sass

environment:
sdk: ">=3.0.0 <4.0.0"

dependencies:
sass: 1.63.6
sass: 1.64.0

dev_dependencies:
dartdoc: ^5.0.0
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: sass
version: 1.64.0-dev
version: 1.64.0
description: A Sass implementation in Dart.
homepage: https://github.com/sass/dart-sass

Expand Down
2 changes: 2 additions & 0 deletions test/dart_api/value/boolean_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ void main() {
});

test("isn't any other type", () {
expect(value.assertCalculation, throwsSassScriptException);
expect(value.assertColor, throwsSassScriptException);
expect(value.assertFunction, throwsSassScriptException);
expect(value.assertMap, throwsSassScriptException);
Expand All @@ -54,6 +55,7 @@ void main() {
});

test("isn't any other type", () {
expect(value.assertCalculation, throwsSassScriptException);
expect(value.assertColor, throwsSassScriptException);
expect(value.assertFunction, throwsSassScriptException);
expect(value.assertMap, throwsSassScriptException);
Expand Down
Loading

0 comments on commit ca2be2a

Please sign in to comment.