From ad4a1692a8166a16fc57fc6f16fc5f0b15dba165 Mon Sep 17 00:00:00 2001 From: Awjin Ahn Date: Thu, 18 Feb 2021 15:48:47 -0600 Subject: [PATCH] Adds RenderOptions to the context of custom functions. (#1236) --- CHANGELOG.md | 5 + lib/src/node.dart | 64 ++++++------ pubspec.yaml | 2 +- test/node_api/function_test.dart | 165 +++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 209d6d40f..1d6bac172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ * Update chokidar version for Node API tests. +### JavaScript API + +* Allow a custom function to access the `render()` options object within its + local context, as `this.options`. + ## 1.32.7 * Allow the null safety release of stream_transform. diff --git a/lib/src/node.dart b/lib/src/node.dart index 1711875a5..065e5543b 100644 --- a/lib/src/node.dart +++ b/lib/src/node.dart @@ -96,7 +96,7 @@ Future _renderAsync(RenderOptions options) async { if (options.data != null) { result = await compileStringAsync(options.data, nodeImporter: _parseImporter(options, start), - functions: _parseFunctions(options, asynch: true), + functions: _parseFunctions(options, start, asynch: true), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), useSpaces: options.indentType != 'tab', @@ -107,7 +107,7 @@ Future _renderAsync(RenderOptions options) async { } else if (options.file != null) { result = await compileAsync(file, nodeImporter: _parseImporter(options, start), - functions: _parseFunctions(options, asynch: true), + functions: _parseFunctions(options, start, asynch: true), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), useSpaces: options.indentType != 'tab', @@ -135,7 +135,7 @@ RenderResult _renderSync(RenderOptions options) { if (options.data != null) { result = compileString(options.data, nodeImporter: _parseImporter(options, start), - functions: _parseFunctions(options).cast(), + functions: _parseFunctions(options, start).cast(), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), useSpaces: options.indentType != 'tab', @@ -146,7 +146,7 @@ RenderResult _renderSync(RenderOptions options) { } else if (options.file != null) { result = compile(file, nodeImporter: _parseImporter(options, start), - functions: _parseFunctions(options).cast(), + functions: _parseFunctions(options, start).cast(), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), useSpaces: options.indentType != 'tab', @@ -186,7 +186,7 @@ JsError _wrapException(Object exception) { /// /// This is typed to always return [AsyncCallable], but in practice it will /// return a `List` if [asynch] is `false`. -List _parseFunctions(RenderOptions options, +List _parseFunctions(RenderOptions options, DateTime start, {bool asynch = false}) { if (options.functions == null) return const []; @@ -200,6 +200,8 @@ List _parseFunctions(RenderOptions options, 'Invalid signature "${signature}": ${error.message}', error.span); } + var context = _contextWithOptions(options, start); + if (options.fiber != null) { result.add(BuiltInCallable.parsed(tuple.item1, tuple.item2, (arguments) { var fiber = options.fiber.current; @@ -211,7 +213,7 @@ List _parseFunctions(RenderOptions options, scheduleMicrotask(() => fiber.run(result)); }) ]; - var result = Function.apply(callback as Function, jsArguments); + var result = (callback as JSFunction).apply(context, jsArguments); return unwrapValue(isUndefined(result) // Run `fiber.yield()` in runZoned() so that Dart resets the current // zone once it's done. Otherwise, interweaving fibers can leave @@ -223,8 +225,8 @@ List _parseFunctions(RenderOptions options, result.add(BuiltInCallable.parsed( tuple.item1, tuple.item2, - (arguments) => unwrapValue(Function.apply( - callback as Function, arguments.map(wrapValue).toList())))); + (arguments) => unwrapValue((callback as JSFunction) + .apply(context, arguments.map(wrapValue).toList())))); } else { result.add(AsyncBuiltInCallable.parsed(tuple.item1, tuple.item2, (arguments) async { @@ -233,7 +235,7 @@ List _parseFunctions(RenderOptions options, ...arguments.map(wrapValue), allowInterop(([Object result]) => completer.complete(result)) ]; - var result = Function.apply(callback as Function, jsArguments); + var result = (callback as JSFunction).apply(context, jsArguments); return unwrapValue( isUndefined(result) ? await completer.future : result); })); @@ -254,27 +256,8 @@ NodeImporter _parseImporter(RenderOptions options, DateTime start) { importers = [options.importer as JSFunction]; } - var includePaths = List.from(options.includePaths ?? []); - RenderContext context; - if (importers.isNotEmpty) { - context = RenderContext( - options: RenderContextOptions( - file: options.file, - data: options.data, - includePaths: - ([p.current, ...includePaths]).join(isWindows ? ';' : ':'), - precision: SassNumber.precision, - style: 1, - indentType: options.indentType == 'tab' ? 1 : 0, - indentWidth: _parseIndentWidth(options.indentWidth) ?? 2, - linefeed: _parseLineFeed(options.linefeed).text, - result: RenderResult( - stats: RenderResultStats( - start: start.millisecondsSinceEpoch, - entry: options.file ?? 'data')))); - context.options.context = context; - } + if (importers.isNotEmpty) context = _contextWithOptions(options, start); if (options.fiber != null) { importers = importers.map((importer) { @@ -297,9 +280,32 @@ NodeImporter _parseImporter(RenderOptions options, DateTime start) { }).toList(); } + var includePaths = List.from(options.includePaths ?? []); return NodeImporter(context, includePaths, importers); } +/// Creates a `this` context that contains the render options. +RenderContext _contextWithOptions(RenderOptions options, DateTime start) { + var includePaths = List.from(options.includePaths ?? []); + var context = RenderContext( + options: RenderContextOptions( + file: options.file, + data: options.data, + includePaths: + ([p.current, ...includePaths]).join(isWindows ? ';' : ':'), + precision: SassNumber.precision, + style: 1, + indentType: options.indentType == 'tab' ? 1 : 0, + indentWidth: _parseIndentWidth(options.indentWidth) ?? 2, + linefeed: _parseLineFeed(options.linefeed).text, + result: RenderResult( + stats: RenderResultStats( + start: start.millisecondsSinceEpoch, + entry: options.file ?? 'data')))); + context.options.context = context; + return context; +} + /// Parse [style] into an [OutputStyle]. OutputStyle _parseOutputStyle(String style) { if (style == null || style == 'expanded') return OutputStyle.expanded; diff --git a/pubspec.yaml b/pubspec.yaml index f255fe8f9..67e91e2cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.32.8-dev +version: 1.32.8 description: A Sass implementation in Dart. author: Sass Team homepage: https://github.com/sass/dart-sass diff --git a/test/node_api/function_test.dart b/test/node_api/function_test.dart index e50600963..e56584025 100644 --- a/test/node_api/function_test.dart +++ b/test/node_api/function_test.dart @@ -11,8 +11,13 @@ import 'dart:js_util'; import 'package:js/js.dart'; import 'package:node_interop/js.dart'; import 'package:test/test.dart'; +import 'package:path/path.dart' as p; + +import 'package:sass/src/io.dart'; +import 'package:sass/src/value/number.dart'; import '../ensure_npm_package.dart'; +import '../hybrid.dart'; import 'api.dart'; import 'utils.dart'; @@ -180,6 +185,166 @@ void main() { }); }); + group('this', () { + String sassPath; + setUp(() async { + sassPath = p.join(sandbox, 'test.scss'); + }); + + test('includes default option values', () { + renderSync(RenderOptions( + data: 'a {b: foo()}', + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + var options = this_.options; + expect(options.includePaths, equals(p.current)); + expect(options.precision, equals(SassNumber.precision)); + expect(options.style, equals(1)); + expect(options.indentType, equals(0)); + expect(options.indentWidth, equals(2)); + expect(options.linefeed, equals('\n')); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + + test('includes the data when rendering via data', () { + renderSync(RenderOptions( + data: 'a {b: foo()}', + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + expect(this_.options.data, equals('a {b: foo()}')); + expect(this_.options.file, isNull); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + + test('includes the filename when rendering via file', () async { + await writeTextFile(sassPath, 'a {b: foo()}'); + renderSync(RenderOptions( + file: sassPath, + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + expect(this_.options.data, isNull); + expect(this_.options.file, equals(sassPath)); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + + test('includes other include paths', () { + renderSync(RenderOptions( + data: 'a {b: foo()}', + includePaths: [sandbox], + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + expect(this_.options.includePaths, + equals('${p.current}${isWindows ? ';' : ':'}$sandbox')); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + + group('can override', () { + test('indentWidth', () { + renderSync(RenderOptions( + data: 'a {b: foo()}', + indentWidth: 5, + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + expect(this_.options.indentWidth, equals(5)); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + + test('indentType', () { + renderSync(RenderOptions( + data: 'a {b: foo()}', + indentType: 'tab', + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + expect(this_.options.indentType, equals(1)); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + + test('linefeed', () { + renderSync(RenderOptions( + data: 'a {b: foo()}', + linefeed: 'cr', + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + expect(this_.options.linefeed, equals('\r')); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + }); + + test('has a circular reference', () { + renderSync(RenderOptions( + data: 'a {b: foo()}', + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + expect(this_.options.context, same(this_)); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + + group('includes render stats with', () { + test('a start time', () { + var start = DateTime.now(); + renderSync(RenderOptions( + data: 'a {b: foo()}', + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + expect(this_.options.result.stats.start, + greaterThanOrEqualTo(start.millisecondsSinceEpoch)); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + + test('a data entry', () { + renderSync(RenderOptions( + data: 'a {b: foo()}', + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + expect(this_.options.result.stats.entry, equals('data')); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + + test('a file entry', () async { + await writeTextFile(sassPath, 'a {b: foo()}'); + renderSync(RenderOptions( + file: sassPath, + functions: jsify({ + 'foo': allowInteropCaptureThis(expectAsync1((RenderContext this_) { + expect(this_.options.result.stats.entry, equals(sassPath)); + return callConstructor(sass.types.Number, [12]); + })) + }), + )); + }); + }); + }); + test("gracefully handles an error from the function", () { var error = renderSyncError(RenderOptions( data: "a {b: foo()}",