From a003e5c31f40dfab5d0873564e1be3b84b8cafc4 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 5 Oct 2017 18:45:26 -0700 Subject: [PATCH] Expose a Dart API for importers Closes #172 --- CHANGELOG.md | 16 +++ lib/sass.dart | 60 +++++++++-- test/dart_api/importer_test.dart | 113 ++++++++++++++++++++ test/dart_api_test.dart | 175 +++++++++++++++++++++++++++++++ test/dart_script_test.dart | 37 ------- 5 files changed, 354 insertions(+), 47 deletions(-) create mode 100644 test/dart_api/importer_test.dart create mode 100644 test/dart_api_test.dart delete mode 100644 test/dart_script_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea8a707..1e6b36b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ * Don't crash when evaluating CSS variables whose names are entirely interpolated (for example, `#{--foo}: ...`). +### Dart API + +* Added an `Importer` class. This can be extended by users to provide support + for custom resolution for `@import` rules. + +* Added built-in `FilesystemImporter` and `PackageImporter` implementations that + support resolving `file:` and `package:` URLs, respectively. + +* Added an `importers` argument to the `compile()` and `compileString()` + functions that provides `Importer`s to use when resolving `@import` rules. + +* Added a `loadPaths` argument to the `compile()` and `compileString()` + functions that provides paths to search for stylesheets when resolving + `@import` rules. This is a shorthand for passing `FilesystemImporter`s to the + `importers` argument. + ## 1.0.0-beta.2 * Add support for the `::slotted()` pseudo-element. diff --git a/lib/sass.dart b/lib/sass.dart index ee1da964..0ef241b6 100644 --- a/lib/sass.dart +++ b/lib/sass.dart @@ -4,22 +4,46 @@ import 'src/compile.dart' as c; import 'src/exception.dart'; +import 'src/importer.dart'; import 'src/sync_package_resolver.dart'; +export 'src/importer.dart'; +export 'src/importer/filesystem.dart'; +export 'src/importer/package.dart'; +export 'src/importer/result.dart'; + /// Loads the Sass file at [path], compiles it to CSS, and returns the result. /// /// If [color] is `true`, this will use terminal colors in warnings. /// -/// If [packageResolver] is provided, it's used to resolve `package:` imports. -/// Otherwise, they aren't supported. It takes a [SyncPackageResolver][] from -/// the `package_resolver` package. +/// Imports are resolved by trying, in order: +/// +/// * Loading a file relative to [path]. +/// +/// * Each importer in [importers]. +/// +/// * Each load path in [loadPaths]. Note that this is a shorthand for adding +/// [FilesystemImporter]s to [importers]. +/// +/// * `package:` resolution using [packageResolver], which is a +/// [SyncPackageResolver][] from the `package_resolver` package. Note that +/// this is a shorthand for adding a [PackageImporter] to [importers]. /// /// [SyncPackageResolver]: https://www.dartdocs.org/documentation/package_resolver/latest/package_resolver/SyncPackageResolver-class.html /// /// Throws a [SassException] if conversion fails. String compile(String path, - {bool color: false, SyncPackageResolver packageResolver}) => - c.compile(path, color: color, packageResolver: packageResolver).css; + {bool color: false, + Iterable importers, + Iterable loadPaths, + SyncPackageResolver packageResolver}) { + var result = c.compile(path, + color: color, + importers: importers, + loadPaths: loadPaths, + packageResolver: packageResolver); + return result.css; +} /// Compiles [source] to CSS and returns the result. /// @@ -27,25 +51,41 @@ String compile(String path, /// otherwise (and by default) it uses SCSS. If [color] is `true`, this will use /// terminal colors in warnings. /// -/// If [packageResolver] is provided, it's used to resolve `package:` imports. -/// Otherwise, they aren't supported. It takes a [SyncPackageResolver][] from -/// the `package_resolver` package. +/// Imports are resolved by trying, in order: +/// +/// * The given [importer], with the imported URL resolved relative to [url]. +/// +/// * Each importer in [importers]. +/// +/// * Each load path in [loadPaths]. Note that this is a shorthand for adding +/// [FilesystemImporter]s to [importers]. +/// +/// * `package:` resolution using [packageResolver], which is a +/// [SyncPackageResolver][] from the `package_resolver` package. Note that +/// this is a shorthand for adding a [PackageImporter] to [importers]. /// /// [SyncPackageResolver]: https://www.dartdocs.org/documentation/package_resolver/latest/package_resolver/SyncPackageResolver-class.html /// -/// The [url] indicates the location from which [source] was loaded. It may may -/// be a [String] or a [Uri]. +/// The [url] indicates the location from which [source] was loaded. It may be a +/// [String] or a [Uri]. If [importer] is passed, [url] must be passed as well +/// and `importer.load(url)` should return `source`. /// /// Throws a [SassException] if conversion fails. String compileString(String source, {bool indented: false, bool color: false, + Iterable importers, + Iterable loadPaths, SyncPackageResolver packageResolver, + Importer importer, url}) { var result = c.compileString(source, indented: indented, color: color, + importers: importers, + loadPaths: loadPaths, packageResolver: packageResolver, + importer: importer, url: url); return result.css; } diff --git a/test/dart_api/importer_test.dart b/test/dart_api/importer_test.dart new file mode 100644 index 00000000..fd06932c --- /dev/null +++ b/test/dart_api/importer_test.dart @@ -0,0 +1,113 @@ +// Copyright 2017 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. + +@TestOn('vm') + +import 'package:test/test.dart'; + +import 'package:sass/sass.dart'; +import 'package:sass/src/exception.dart'; + +main() { + test("uses an importer to resolve an @import", () { + var css = compileString('@import "orange";', importers: [ + new _TestImporter((url) => url, (url) { + return new ImporterResult('.$url {color: $url}', indented: false); + }) + ]); + + expect(css, equals(".orange {\n color: orange;\n}")); + }); + + test("passes the canonicalized URL to the importer", () { + var css = compileString('@import "orange";', importers: [ + new _TestImporter((url) => new Uri(path: 'blue'), (url) { + return new ImporterResult('.$url {color: $url}', indented: false); + }) + ]); + + expect(css, equals(".blue {\n color: blue;\n}")); + }); + + test("only invokes the importer once for a given canonicalization", () { + var css = compileString(""" + @import "orange"; + @import "orange"; + """, importers: [ + new _TestImporter( + (url) => new Uri(path: 'blue'), + expectAsync1((url) { + return new ImporterResult('.$url {color: $url}', indented: false); + }, count: 1)) + ]); + + expect(css, equals(""" +.blue { + color: blue; +} + +.blue { + color: blue; +}""")); + }); + + test("wraps an error in canonicalize()", () { + expect(() { + compileString('@import "orange";', importers: [ + new _TestImporter((url) { + throw "this import is bad actually"; + }, expectAsync1((_) => null, count: 0)) + ]); + }, throwsA(predicate((error) { + expect(error, new isInstanceOf()); + expect( + error.toString(), startsWith("Error: this import is bad actually")); + return true; + }))); + }); + + test("wraps an error in load()", () { + expect(() { + compileString('@import "orange";', importers: [ + new _TestImporter((url) => url, (url) { + throw "this import is bad actually"; + }) + ]); + }, throwsA(predicate((error) { + expect(error, new isInstanceOf()); + expect( + error.toString(), startsWith("Error: this import is bad actually")); + return true; + }))); + }); + + test("prefers .message to .toString() for an importer error", () { + expect(() { + compileString('@import "orange";', importers: [ + new _TestImporter((url) => url, (url) { + throw new FormatException("bad format somehow"); + }) + ]); + }, throwsA(predicate((error) { + expect(error, new isInstanceOf()); + // FormatException.toString() starts with "FormatException:", but + // the error message should not. + expect(error.toString(), startsWith("Error: bad format somehow")); + return true; + }))); + }); +} + +/// An [Importer] whose [canonicalize] and [load] methods are provided by +/// closures. +class _TestImporter extends Importer { + final Uri Function(Uri url) _canonicalize; + final ImporterResult Function(Uri url) _load; + + _TestImporter(this._canonicalize, this._load); + + Uri canonicalize(Uri url) => _canonicalize(url); + + ImporterResult load(Uri url) => _load(url); +} diff --git a/test/dart_api_test.dart b/test/dart_api_test.dart new file mode 100644 index 00000000..d012043d --- /dev/null +++ b/test/dart_api_test.dart @@ -0,0 +1,175 @@ +// Copyright 2017 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. + +@TestOn('vm') + +import 'package:package_resolver/package_resolver.dart'; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import 'package:sass/sass.dart'; +import 'package:sass/src/exception.dart'; +import 'package:sass/src/util/path.dart'; + +main() { + group("importers", () { + test("is used to resolve imports", () async { + await d.dir("subdir", [d.file("subtest.scss", "a {b: c}")]).create(); + await d.file("test.scss", '@import "subtest.scss";').create(); + + var css = compile(p.join(d.sandbox, "test.scss"), + importers: [new FilesystemImporter(p.join(d.sandbox, 'subdir'))]); + expect(css, equals("a {\n b: c;\n}")); + }); + + test("are checked in order", () async { + await d + .dir("first", [d.file("other.scss", "a {b: from-first}")]).create(); + await d + .dir("second", [d.file("other.scss", "a {b: from-second}")]).create(); + await d.file("test.scss", '@import "other";').create(); + + var css = compile(p.join(d.sandbox, "test.scss"), importers: [ + new FilesystemImporter(p.join(d.sandbox, 'first')), + new FilesystemImporter(p.join(d.sandbox, 'second')) + ]); + expect(css, equals("a {\n b: from-first;\n}")); + }); + }); + + group("loadPaths", () { + test("is used to import file: URLs", () async { + await d.dir("subdir", [d.file("subtest.scss", "a {b: c}")]).create(); + await d.file("test.scss", '@import "subtest.scss";').create(); + + var css = compile(p.join(d.sandbox, "test.scss"), + loadPaths: [p.join(d.sandbox, 'subdir')]); + expect(css, equals("a {\n b: c;\n}")); + }); + + test("can import partials", () async { + await d.dir("subdir", [d.file("_subtest.scss", "a {b: c}")]).create(); + await d.file("test.scss", '@import "subtest.scss";').create(); + + var css = compile(p.join(d.sandbox, "test.scss"), + loadPaths: [p.join(d.sandbox, 'subdir')]); + expect(css, equals("a {\n b: c;\n}")); + }); + + test("adds a .scss extension", () async { + await d.dir("subdir", [d.file("subtest.scss", "a {b: c}")]).create(); + await d.file("test.scss", '@import "subtest";').create(); + + var css = compile(p.join(d.sandbox, "test.scss"), + loadPaths: [p.join(d.sandbox, 'subdir')]); + expect(css, equals("a {\n b: c;\n}")); + }); + + test("adds a .sass extension", () async { + await d.dir("subdir", [d.file("subtest.sass", "a\n b: c")]).create(); + await d.file("test.scss", '@import "subtest";').create(); + + var css = compile(p.join(d.sandbox, "test.scss"), + loadPaths: [p.join(d.sandbox, 'subdir')]); + expect(css, equals("a {\n b: c;\n}")); + }); + + test("are checked in order", () async { + await d + .dir("first", [d.file("other.scss", "a {b: from-first}")]).create(); + await d + .dir("second", [d.file("other.scss", "a {b: from-second}")]).create(); + await d.file("test.scss", '@import "other";').create(); + + var css = compile(p.join(d.sandbox, "test.scss"), + loadPaths: [p.join(d.sandbox, 'first'), p.join(d.sandbox, 'second')]); + expect(css, equals("a {\n b: from-first;\n}")); + }); + }); + + group("packageResolver", () { + test("is used to import package: URLs", () async { + await d.dir("subdir", [d.file("test.scss", "a {b: 1 + 2}")]).create(); + + await d + .file("test.scss", '@import "package:fake_package/test";') + .create(); + var resolver = new SyncPackageResolver.config( + {"fake_package": p.toUri(p.join(d.sandbox, 'subdir'))}); + + var css = + compile(p.join(d.sandbox, "test.scss"), packageResolver: resolver); + expect(css, equals("a {\n b: 3;\n}")); + }); + + test("doesn't import a package URL from a missing package", () async { + await d + .file("test.scss", '@import "package:fake_package/test_aux";') + .create(); + var resolver = new SyncPackageResolver.config({}); + + expect(() => compile(d.sandbox + "/test.scss", packageResolver: resolver), + throwsA(new isInstanceOf())); + }); + }); + + group("import precedence", () { + test("relative imports take precedence over importers", () async { + await d.dir( + "subdir", [d.file("other.scss", "a {b: from-load-path}")]).create(); + await d.file("other.scss", "a {b: from-relative}").create(); + await d.file("test.scss", '@import "other";').create(); + + var css = compile(p.join(d.sandbox, "test.scss"), + importers: [new FilesystemImporter(p.join(d.sandbox, 'subdir'))]); + expect(css, equals("a {\n b: from-relative;\n}")); + }); + + test("the original importer takes precedence over other importers", + () async { + await d.dir( + "original", [d.file("other.scss", "a {b: from-original}")]).create(); + await d + .dir("other", [d.file("other.scss", "a {b: from-other}")]).create(); + + var css = compileString('@import "other";', + importer: new FilesystemImporter(p.join(d.sandbox, 'original')), + url: p.toUri(p.join(d.sandbox, 'original', 'test.scss')), + importers: [new FilesystemImporter(p.join(d.sandbox, 'other'))]); + expect(css, equals("a {\n b: from-original;\n}")); + }); + + test("importers take precedence over load paths", () async { + await d.dir("load-path", + [d.file("other.scss", "a {b: from-load-path}")]).create(); + await d.dir( + "importer", [d.file("other.scss", "a {b: from-importer}")]).create(); + await d.file("test.scss", '@import "other";').create(); + + var css = compile(p.join(d.sandbox, "test.scss"), + importers: [new FilesystemImporter(p.join(d.sandbox, 'importer'))], + loadPaths: [p.join(d.sandbox, 'load-path')]); + expect(css, equals("a {\n b: from-importer;\n}")); + }); + + test("importers take precedence over packageResolver", () async { + await d.dir("package", + [d.file("other.scss", "a {b: from-package-resolver}")]).create(); + await d.dir( + "importer", [d.file("other.scss", "a {b: from-importer}")]).create(); + await d + .file("test.scss", '@import "package:fake_package/other";') + .create(); + + var css = compile(p.join(d.sandbox, "test.scss"), + importers: [ + new PackageImporter(new SyncPackageResolver.config( + {"fake_package": p.toUri(p.join(d.sandbox, 'importer'))})) + ], + packageResolver: new SyncPackageResolver.config( + {"fake_package": p.toUri(p.join(d.sandbox, 'package'))})); + expect(css, equals("a {\n b: from-importer;\n}")); + }); + }); +} diff --git a/test/dart_script_test.dart b/test/dart_script_test.dart deleted file mode 100644 index 0e87dbc7..00000000 --- a/test/dart_script_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2017 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. - -@TestOn('vm') - -import 'package:package_resolver/package_resolver.dart'; -import 'package:test/test.dart'; -import 'package:test_descriptor/test_descriptor.dart' as d; - -import 'package:sass/sass.dart'; -import 'package:sass/src/exception.dart'; -import 'package:sass/src/util/path.dart'; - -main() { - test("successfully imports a package URL", () async { - await d.dir("subdir", [d.file("test.scss", "a {b: 1 + 2}")]).create(); - - await d.file("test.scss", '@import "package:fake_package/test";').create(); - var resolver = new SyncPackageResolver.config( - {"fake_package": p.toUri(p.join(d.sandbox, 'subdir'))}); - - var css = - compile(p.join(d.sandbox, "test.scss"), packageResolver: resolver); - expect(css, equals("a {\n b: 3;\n}")); - }); - - test("imports a package URL from a missing package", () async { - await d - .file("test.scss", '@import "package:fake_package/test_aux";') - .create(); - var resolver = new SyncPackageResolver.config({}); - - expect(() => compile(d.sandbox + "/test.scss", packageResolver: resolver), - throwsA(new isInstanceOf())); - }); -}