From 9ee5408211f1f2fc3c34e280552f3c3afd899156 Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Tue, 6 Feb 2024 17:58:41 -0500 Subject: [PATCH] [Package Importer] Dart Implementation (#2130) Co-authored-by: Jonny Gerig Meyer --- lib/src/async_compile.dart | 9 +- lib/src/async_import_cache.dart | 6 + lib/src/compile.dart | 11 +- lib/src/embedded/compilation_dispatcher.dart | 5 + lib/src/embedded/importer/file.dart | 12 +- lib/src/executable/compile_stylesheet.dart | 6 +- lib/src/executable/repl.dart | 2 +- lib/src/executable/watch.dart | 6 +- lib/src/import_cache.dart | 8 +- lib/src/importer/filesystem.dart | 3 + lib/src/importer/js_to_dart/async_file.dart | 16 +- lib/src/importer/js_to_dart/file.dart | 16 +- lib/src/importer/node_package.dart | 387 +++++++++++++++++++ lib/src/importer/package.dart | 17 +- lib/src/js.dart | 1 + lib/src/js/compile.dart | 20 + lib/src/js/exports.dart | 1 + lib/src/js/legacy.dart | 24 ++ lib/src/js/legacy/render_options.dart | 3 + lib/src/js/utils.dart | 4 + lib/src/visitor/async_evaluate.dart | 11 +- lib/src/visitor/evaluate.dart | 11 +- pubspec.yaml | 2 +- 23 files changed, 512 insertions(+), 69 deletions(-) create mode 100644 lib/src/importer/node_package.dart diff --git a/lib/src/async_compile.dart b/lib/src/async_compile.dart index daa2233db..a940d3f26 100644 --- a/lib/src/async_compile.dart +++ b/lib/src/async_compile.dart @@ -26,7 +26,8 @@ import 'visitor/serialize.dart'; /// Like [compileAsync] in `lib/sass.dart`, but provides more options to support /// the node-sass compatible API and the executable. /// -/// At most one of `importCache` and `nodeImporter` may be provided at once. +/// If both `importCache` and `nodeImporter` are provided, the importers in +/// `importCache` will be evaluated before `nodeImporter`. Future compileAsync(String path, {Syntax? syntax, Logger? logger, @@ -56,7 +57,7 @@ Future compileAsync(String path, (syntax == null || syntax == Syntax.forPath(path))) { importCache ??= AsyncImportCache.none(logger: logger); stylesheet = (await importCache.importCanonical( - FilesystemImporter('.'), p.toUri(canonicalize(path)), + FilesystemImporter.cwd, p.toUri(canonicalize(path)), originalUrl: p.toUri(path)))!; } else { stylesheet = Stylesheet.parse( @@ -69,7 +70,7 @@ Future compileAsync(String path, logger, importCache, nodeImporter, - FilesystemImporter('.'), + FilesystemImporter.cwd, functions, style, useSpaces, @@ -121,7 +122,7 @@ Future compileStringAsync(String source, logger, importCache, nodeImporter, - importer ?? (isBrowser ? NoOpImporter() : FilesystemImporter('.')), + importer ?? (isBrowser ? NoOpImporter() : FilesystemImporter.cwd), functions, style, useSpaces, diff --git a/lib/src/async_import_cache.dart b/lib/src/async_import_cache.dart index 9b08e5597..0deb6285f 100644 --- a/lib/src/async_import_cache.dart +++ b/lib/src/async_import_cache.dart @@ -104,6 +104,12 @@ final class AsyncImportCache { : _importers = const [], _logger = logger ?? const Logger.stderr(); + /// Creates an import cache without any globally-available importers, and only + /// the passed in importers. + AsyncImportCache.only(Iterable importers, {Logger? logger}) + : _importers = List.unmodifiable(importers), + _logger = logger ?? const Logger.stderr(); + /// Converts the user's [importers], [loadPaths], and [packageConfig] /// options into a single list of importers. static List _toImporters(Iterable? importers, diff --git a/lib/src/compile.dart b/lib/src/compile.dart index b951c8036..94221405d 100644 --- a/lib/src/compile.dart +++ b/lib/src/compile.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_compile.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: 5178e366228bde7854df12221393857bb3022628 +// Checksum: a9421a2975e79ad591ae32474cd076e1379d0e75 // // ignore_for_file: unused_import @@ -35,7 +35,8 @@ import 'visitor/serialize.dart'; /// Like [compile] in `lib/sass.dart`, but provides more options to support /// the node-sass compatible API and the executable. /// -/// At most one of `importCache` and `nodeImporter` may be provided at once. +/// If both `importCache` and `nodeImporter` are provided, the importers in +/// `importCache` will be evaluated before `nodeImporter`. CompileResult compile(String path, {Syntax? syntax, Logger? logger, @@ -65,7 +66,7 @@ CompileResult compile(String path, (syntax == null || syntax == Syntax.forPath(path))) { importCache ??= ImportCache.none(logger: logger); stylesheet = importCache.importCanonical( - FilesystemImporter('.'), p.toUri(canonicalize(path)), + FilesystemImporter.cwd, p.toUri(canonicalize(path)), originalUrl: p.toUri(path))!; } else { stylesheet = Stylesheet.parse( @@ -78,7 +79,7 @@ CompileResult compile(String path, logger, importCache, nodeImporter, - FilesystemImporter('.'), + FilesystemImporter.cwd, functions, style, useSpaces, @@ -130,7 +131,7 @@ CompileResult compileString(String source, logger, importCache, nodeImporter, - importer ?? (isBrowser ? NoOpImporter() : FilesystemImporter('.')), + importer ?? (isBrowser ? NoOpImporter() : FilesystemImporter.cwd), functions, style, useSpaces, diff --git a/lib/src/embedded/compilation_dispatcher.dart b/lib/src/embedded/compilation_dispatcher.dart index 4e390fcd9..356130d84 100644 --- a/lib/src/embedded/compilation_dispatcher.dart +++ b/lib/src/embedded/compilation_dispatcher.dart @@ -11,6 +11,7 @@ import 'package:native_synchronization/mailbox.dart'; import 'package:path/path.dart' as p; import 'package:protobuf/protobuf.dart'; import 'package:sass/sass.dart' as sass; +import 'package:sass/src/importer/node_package.dart' as npi; import '../logger.dart'; import '../value/function.dart'; @@ -226,6 +227,10 @@ final class CompilationDispatcher { case InboundMessage_CompileRequest_Importer_Importer.notSet: _checkNoNonCanonicalScheme(importer); return null; + + case InboundMessage_CompileRequest_Importer_Importer.nodePackageImporter: + return npi.NodePackageImporter( + importer.nodePackageImporter.entryPointDirectory); } } diff --git a/lib/src/embedded/importer/file.dart b/lib/src/embedded/importer/file.dart index d6096eb7b..57d97ddf9 100644 --- a/lib/src/embedded/importer/file.dart +++ b/lib/src/embedded/importer/file.dart @@ -6,12 +6,6 @@ import '../../importer.dart'; import '../embedded_sass.pb.dart' hide SourceSpan; import 'base.dart'; -/// A filesystem importer to use for most implementation details of -/// [FileImporter]. -/// -/// This allows us to avoid duplicating logic between the two importers. -final _filesystemImporter = FilesystemImporter('.'); - /// An importer that asks the host to resolve imports in a simplified, /// file-system-centric way. final class FileImporter extends ImporterBase { @@ -21,7 +15,7 @@ final class FileImporter extends ImporterBase { FileImporter(super.dispatcher, this._importerId); Uri? canonicalize(Uri url) { - if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); + if (url.scheme == 'file') return FilesystemImporter.cwd.canonicalize(url); var request = OutboundMessage_FileImportRequest() ..importerId = _importerId @@ -39,7 +33,7 @@ final class FileImporter extends ImporterBase { throw 'The file importer must return a file: URL, was "$url"'; } - return _filesystemImporter.canonicalize(url); + return FilesystemImporter.cwd.canonicalize(url); case InboundMessage_FileImportResponse_Result.error: throw response.error; @@ -49,7 +43,7 @@ final class FileImporter extends ImporterBase { } } - ImporterResult? load(Uri url) => _filesystemImporter.load(url); + ImporterResult? load(Uri url) => FilesystemImporter.cwd.load(url); bool isNonCanonicalScheme(String scheme) => scheme != 'file'; diff --git a/lib/src/executable/compile_stylesheet.dart b/lib/src/executable/compile_stylesheet.dart index 70b52ba10..ba85610af 100644 --- a/lib/src/executable/compile_stylesheet.dart +++ b/lib/src/executable/compile_stylesheet.dart @@ -68,7 +68,7 @@ Future<(int, String, String?)?> compileStylesheet(ExecutableOptions options, Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options, StylesheetGraph graph, String? source, String? destination, {bool ifModified = false}) async { - var importer = FilesystemImporter('.'); + var importer = FilesystemImporter.cwd; if (ifModified) { try { if (source != null && @@ -102,7 +102,7 @@ Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options, syntax: syntax, logger: options.logger, importCache: importCache, - importer: FilesystemImporter('.'), + importer: FilesystemImporter.cwd, style: options.style, quietDeps: options.quietDeps, verbose: options.verbose, @@ -127,7 +127,7 @@ Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options, syntax: syntax, logger: options.logger, importCache: graph.importCache, - importer: FilesystemImporter('.'), + importer: FilesystemImporter.cwd, style: options.style, quietDeps: options.quietDeps, verbose: options.verbose, diff --git a/lib/src/executable/repl.dart b/lib/src/executable/repl.dart index d460b40e0..6e0124bde 100644 --- a/lib/src/executable/repl.dart +++ b/lib/src/executable/repl.dart @@ -22,7 +22,7 @@ Future repl(ExecutableOptions options) async { var repl = Repl(prompt: '>> '); var logger = TrackingLogger(options.logger); var evaluator = Evaluator( - importer: FilesystemImporter('.'), + importer: FilesystemImporter.cwd, importCache: ImportCache(loadPaths: options.loadPaths, logger: logger), logger: logger); await for (String line in repl.runAsync()) { diff --git a/lib/src/executable/watch.dart b/lib/src/executable/watch.dart index c8a222b0b..9e1db78e9 100644 --- a/lib/src/executable/watch.dart +++ b/lib/src/executable/watch.dart @@ -39,7 +39,7 @@ Future watch(ExecutableOptions options, StylesheetGraph graph) async { var sourcesToDestinations = _sourcesToDestinations(options); for (var source in sourcesToDestinations.keys) { graph.addCanonical( - FilesystemImporter('.'), p.toUri(canonicalize(source)), p.toUri(source), + FilesystemImporter.cwd, p.toUri(canonicalize(source)), p.toUri(source), recanonicalize: false); } var success = await compileStylesheets(options, graph, sourcesToDestinations, @@ -130,7 +130,7 @@ final class _Watcher { await compileStylesheets(_options, _graph, {path: destination}, ifModified: true); var downstream = _graph.addCanonical( - FilesystemImporter('.'), _canonicalize(path), p.toUri(path)); + FilesystemImporter.cwd, _canonicalize(path), p.toUri(path)); return await _recompileDownstream(downstream) && success; } @@ -144,7 +144,7 @@ final class _Watcher { if (_destinationFor(path) case var destination?) _delete(destination); } - var downstream = _graph.remove(FilesystemImporter('.'), url); + var downstream = _graph.remove(FilesystemImporter.cwd, url); return await _recompileDownstream(downstream); } diff --git a/lib/src/import_cache.dart b/lib/src/import_cache.dart index 397e676aa..e34f0a7ee 100644 --- a/lib/src/import_cache.dart +++ b/lib/src/import_cache.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_import_cache.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: 342e907cf10e1dd80d7045fc32db43c74376654e +// Checksum: d157b83599dbc07a80ac6cb5ffdf5dde03b60376 // // ignore_for_file: unused_import @@ -106,6 +106,12 @@ final class ImportCache { : _importers = const [], _logger = logger ?? const Logger.stderr(); + /// Creates an import cache without any globally-available importers, and only + /// the passed in importers. + ImportCache.only(Iterable importers, {Logger? logger}) + : _importers = List.unmodifiable(importers), + _logger = logger ?? const Logger.stderr(); + /// Converts the user's [importers], [loadPaths], and [packageConfig] /// options into a single list of importers. static List _toImporters(Iterable? importers, diff --git a/lib/src/importer/filesystem.dart b/lib/src/importer/filesystem.dart index 31af69829..47b0ae288 100644 --- a/lib/src/importer/filesystem.dart +++ b/lib/src/importer/filesystem.dart @@ -22,6 +22,9 @@ class FilesystemImporter extends Importer { /// Creates an importer that loads files relative to [loadPath]. FilesystemImporter(String loadPath) : _loadPath = p.absolute(loadPath); + /// Creates an importer relative to the current working directory. + static final cwd = FilesystemImporter('.'); + Uri? canonicalize(Uri url) { if (url.scheme != 'file' && url.scheme != '') return null; return resolveImportPath(p.join(_loadPath, p.fromUri(url))) diff --git a/lib/src/importer/js_to_dart/async_file.dart b/lib/src/importer/js_to_dart/async_file.dart index e984531dc..7be4b9461 100644 --- a/lib/src/importer/js_to_dart/async_file.dart +++ b/lib/src/importer/js_to_dart/async_file.dart @@ -17,12 +17,6 @@ import '../filesystem.dart'; import '../result.dart'; import '../utils.dart'; -/// A filesystem importer to use for most implementation details of -/// [JSToDartAsyncFileImporter]. -/// -/// This allows us to avoid duplicating logic between the two importers. -final _filesystemImporter = FilesystemImporter('.'); - /// A wrapper for a potentially-asynchronous JS API file importer that exposes /// it as a Dart [AsyncImporter]. final class JSToDartAsyncFileImporter extends AsyncImporter { @@ -32,7 +26,7 @@ final class JSToDartAsyncFileImporter extends AsyncImporter { JSToDartAsyncFileImporter(this._findFileUrl); FutureOr canonicalize(Uri url) async { - if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); + if (url.scheme == 'file') return FilesystemImporter.cwd.canonicalize(url); var result = wrapJSExceptions(() => _findFileUrl( url.toString(), @@ -52,16 +46,16 @@ final class JSToDartAsyncFileImporter extends AsyncImporter { '"$url".')); } - return _filesystemImporter.canonicalize(resultUrl); + return FilesystemImporter.cwd.canonicalize(resultUrl); } - ImporterResult? load(Uri url) => _filesystemImporter.load(url); + ImporterResult? load(Uri url) => FilesystemImporter.cwd.load(url); DateTime modificationTime(Uri url) => - _filesystemImporter.modificationTime(url); + FilesystemImporter.cwd.modificationTime(url); bool couldCanonicalize(Uri url, Uri canonicalUrl) => - _filesystemImporter.couldCanonicalize(url, canonicalUrl); + FilesystemImporter.cwd.couldCanonicalize(url, canonicalUrl); bool isNonCanonicalScheme(String scheme) => scheme != 'file'; } diff --git a/lib/src/importer/js_to_dart/file.dart b/lib/src/importer/js_to_dart/file.dart index 9ad474d00..e3302f881 100644 --- a/lib/src/importer/js_to_dart/file.dart +++ b/lib/src/importer/js_to_dart/file.dart @@ -12,12 +12,6 @@ import '../../js/utils.dart'; import '../../util/nullable.dart'; import '../utils.dart'; -/// A filesystem importer to use for most implementation details of -/// [JSToDartAsyncFileImporter]. -/// -/// This allows us to avoid duplicating logic between the two importers. -final _filesystemImporter = FilesystemImporter('.'); - /// A wrapper for a potentially-asynchronous JS API file importer that exposes /// it as a Dart [AsyncImporter]. final class JSToDartFileImporter extends Importer { @@ -27,7 +21,7 @@ final class JSToDartFileImporter extends Importer { JSToDartFileImporter(this._findFileUrl); Uri? canonicalize(Uri url) { - if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); + if (url.scheme == 'file') return FilesystemImporter.cwd.canonicalize(url); var result = wrapJSExceptions(() => _findFileUrl( url.toString(), @@ -51,16 +45,16 @@ final class JSToDartFileImporter extends Importer { '"$url".')); } - return _filesystemImporter.canonicalize(resultUrl); + return FilesystemImporter.cwd.canonicalize(resultUrl); } - ImporterResult? load(Uri url) => _filesystemImporter.load(url); + ImporterResult? load(Uri url) => FilesystemImporter.cwd.load(url); DateTime modificationTime(Uri url) => - _filesystemImporter.modificationTime(url); + FilesystemImporter.cwd.modificationTime(url); bool couldCanonicalize(Uri url, Uri canonicalUrl) => - _filesystemImporter.couldCanonicalize(url, canonicalUrl); + FilesystemImporter.cwd.couldCanonicalize(url, canonicalUrl); bool isNonCanonicalScheme(String scheme) => scheme != 'file'; } diff --git a/lib/src/importer/node_package.dart b/lib/src/importer/node_package.dart new file mode 100644 index 000000000..ff7b51ca7 --- /dev/null +++ b/lib/src/importer/node_package.dart @@ -0,0 +1,387 @@ +// 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 'package:cli_pkg/js.dart'; +import 'package:collection/collection.dart'; +import 'package:sass/src/util/map.dart'; +import 'package:sass/src/util/nullable.dart'; + +import '../importer.dart'; +import './utils.dart'; +import 'dart:convert'; +import '../io.dart'; +import 'package:path/path.dart' as p; + +/// An [Importer] that resolves `pkg:` URLs using the Node resolution algorithm. +class NodePackageImporter extends Importer { + /// The starting path for canonicalizations without a containing URL. + late final String _entryPointDirectory; + + /// Creates a Node package importer with the associated entry point. + NodePackageImporter(String? entryPointDirectory) { + if (entryPointDirectory == null) { + throw "The Node package importer cannot determine an entry point " + "because `require.main.filename` is not defined. " + "Please provide an `entryPointDirectory` to the `NodePackageImporter`."; + } else if (isBrowser) { + throw "The Node package importer cannot be used without a filesystem."; + } + _entryPointDirectory = p.absolute(entryPointDirectory); + } + + @override + bool isNonCanonicalScheme(String scheme) => scheme == 'pkg'; + + @override + Uri? canonicalize(Uri url) { + if (url.scheme == 'file') return FilesystemImporter.cwd.canonicalize(url); + if (url.scheme != 'pkg') return null; + + if (url.hasAuthority) { + throw "A pkg: URL must not have a host, port, username or password."; + } else if (p.url.isAbsolute(url.path)) { + throw "A pkg: URL's path must not begin with /."; + } else if (url.path.isEmpty) { + throw "A pkg: URL must not have an empty path."; + } else if (url.hasQuery || url.hasFragment) { + throw "A pkg: URL must not have a query or fragment."; + } + + var baseDirectory = containingUrl?.scheme == 'file' + ? p.dirname(p.fromUri(containingUrl!)) + : _entryPointDirectory; + + var (packageName, subpath) = _packageNameAndSubpath(url.path); + + // If the package name is not a valid Node package name, return null in case + // another importer can handle. + if (packageName.startsWith('.') || + packageName.contains('\\') || + packageName.contains('%') || + (packageName.startsWith('@') && + !packageName.contains(p.url.separator))) { + return null; + } + + var packageRoot = _resolvePackageRoot(packageName, baseDirectory); + + if (packageRoot == null) return null; + var jsonPath = p.join(packageRoot, 'package.json'); + + var jsonString = readFile(jsonPath); + Map packageManifest; + try { + packageManifest = json.decode(jsonString) as Map; + } catch (e) { + throw "Failed to parse $jsonPath for \"pkg:$packageName\": $e"; + } + + if (_resolvePackageExports( + packageRoot, subpath, packageManifest, packageName) + case var resolved?) { + if (_validExtensions.contains(p.extension(resolved))) { + return p.toUri(p.canonicalize(resolved)); + } else { + throw "The export for '${subpath ?? "root"}' in " + "'$packageName' resolved to '${resolved.toString()}', " + "which is not a '.scss', '.sass', or '.css' file."; + } + } + // If no subpath, attempt to resolve `sass` or `style` key in package.json, + // then `index` file at package root, resolved for file extensions and + // partials. + if (subpath == null) { + var rootPath = _resolvePackageRootValues(packageRoot, packageManifest); + return rootPath != null ? p.toUri(p.canonicalize(rootPath)) : null; + } + + // If there is a subpath, attempt to resolve the path relative to the + // package root, and resolve for file extensions and partials. + var subpathInRoot = p.join(packageRoot, subpath); + return FilesystemImporter.cwd.canonicalize(p.toUri(subpathInRoot)); + } + + @override + ImporterResult? load(Uri url) => FilesystemImporter.cwd.load(url); + + /// Splits a [bare import + /// specifier](https://nodejs.org/api/esm.html#import-specifiers) `specifier` + /// into its package name and subpath, if one exists. + /// + /// Because this is a bare import specifier and not a path, we always use `/` + /// to avoid invalid values on non-Posix machines. + (String, String?) _packageNameAndSubpath(String specifier) { + var parts = p.url.split(specifier); + var name = p.fromUri(parts.removeAt(0)); + + if (name.startsWith('@')) { + if (parts.isNotEmpty) name = p.url.join(name, parts.removeAt(0)); + } + var subpath = parts.isNotEmpty ? p.fromUri(p.url.joinAll(parts)) : null; + return (name, subpath); + } + + /// Returns an absolute path to the root directory for the most proximate + /// installed `packageName`. + /// + /// Implementation of `PACKAGE_RESOLVE` from the [Resolution Algorithm + /// Specification](https://nodejs.org/api/esm.html#resolution-algorithm-specification). + String? _resolvePackageRoot(String packageName, String baseDirectory) { + while (true) { + var potentialPackage = p.join(baseDirectory, 'node_modules', packageName); + if (dirExists(potentialPackage)) return potentialPackage; + // baseDirectory has now reached root without finding a match. + if (p.split(baseDirectory).length == 1) return null; + baseDirectory = p.dirname(baseDirectory); + } + } + + /// Returns a file path specified by the `sass` or `style` values in a package + /// manifest, or an `index` file relative to the package root. + String? _resolvePackageRootValues( + String packageRoot, Map packageManifest) { + if (packageManifest['sass'] case String sassValue + when _validExtensions.contains(p.url.extension(sassValue))) { + return p.join(packageRoot, sassValue); + } else if (packageManifest['style'] case String styleValue + when _validExtensions.contains(p.url.extension(styleValue))) { + return p.join(packageRoot, styleValue); + } + + var result = resolveImportPath(p.join(packageRoot, 'index')); + return result; + } + + /// Returns a file path specified by a `subpath` in the `exports` section of + /// package.json. + /// + /// `packageName` is used for error reporting. + String? _resolvePackageExports(String packageRoot, String? subpath, + Map packageManifest, String packageName) { + var exports = packageManifest['exports'] as Object?; + if (exports == null) return null; + var subpathVariants = _exportsToCheck(subpath); + if (_nodePackageExportsResolve( + packageRoot, subpathVariants, exports, subpath, packageName) + case var path?) { + return path; + } + + if (subpath != null && p.url.extension(subpath).isNotEmpty) return null; + + var subpathIndexVariants = _exportsToCheck(subpath, addIndex: true); + if (_nodePackageExportsResolve( + packageRoot, subpathIndexVariants, exports, subpath, packageName) + case var path?) { + return path; + } + + return null; + } + + /// Returns the path to one subpath variant, resolved in the `exports` of a + /// package manifest. + /// + /// Throws an error if multiple `subpathVariants` match, and null if none + /// match. + /// + /// Implementation of `PACKAGE_EXPORTS_RESOLVE` from the [Resolution Algorithm + /// Specification](https://nodejs.org/api/esm.html#resolution-algorithm-specification). + String? _nodePackageExportsResolve( + String packageRoot, + List subpathVariants, + Object exports, + String? subpath, + String packageName) { + if (exports is Map && + exports.keys.any((key) => key.startsWith('.')) && + exports.keys.any((key) => !key.startsWith('.'))) { + throw '`exports` in $packageName can not have both conditions and paths ' + 'at the same level.\n' + 'Found ${exports.keys.map((key) => '"$key"').join(',')} in ' + '${p.join(packageRoot, 'package.json')}.'; + } + + var matches = subpathVariants + .map((String? variant) { + if (variant == null) { + return _getMainExport(exports).andThen((mainExport) => + _packageTargetResolve(variant, mainExport, packageRoot)); + } else if (exports is! Map || + exports.keys.every((key) => !key.startsWith('.'))) { + return null; + } + var matchKey = "./${p.toUri(variant)}"; + if (exports.containsKey(matchKey) && + exports[matchKey] != null && + !matchKey.contains('*')) { + return _packageTargetResolve( + matchKey, exports[matchKey] as Object, packageRoot); + } + + var expansionKeys = [ + for (var key in exports.keys) + if ('*'.allMatches(key).length == 1) key + ]..sort(_compareExpansionKeys); + + for (var expansionKey in expansionKeys) { + var [patternBase, patternTrailer] = expansionKey.split('*'); + if (!matchKey.startsWith(patternBase)) continue; + if (matchKey == patternBase) continue; + if (patternTrailer.isEmpty || + (matchKey.endsWith(patternTrailer) && + matchKey.length >= expansionKey.length)) { + var target = exports[expansionKey] as Object?; + if (target == null) continue; + var patternMatch = matchKey.substring( + patternBase.length, matchKey.length - patternTrailer.length); + return _packageTargetResolve( + variant, target, packageRoot, patternMatch); + } + } + + return null; + }) + .whereNotNull() + .toList(); + + return switch (matches) { + [var path] => path, + [] => null, + var paths => + throw "Unable to determine which of multiple potential resolutions " + "found for ${subpath ?? 'root'} in $packageName should be used. " + "\n\nFound:\n" + "${paths.join('\n')}" + }; + } + + /// Implementation of the `PATTERN_KEY_COMPARE` comparator from + /// https://nodejs.org/api/esm.html#resolution-algorithm-specification. + int _compareExpansionKeys(String keyA, String keyB) { + var baseLengthA = keyA.contains('*') ? keyA.indexOf('*') + 1 : keyA.length; + var baseLengthB = keyB.contains('*') ? keyB.indexOf('*') + 1 : keyB.length; + if (baseLengthA > baseLengthB) return -1; + if (baseLengthB > baseLengthA) return 1; + if (!keyA.contains("*")) return 1; + if (!keyB.contains("*")) return -1; + if (keyA.length > keyB.length) return -1; + if (keyB.length > keyA.length) return 1; + return 0; + } + + /// Returns a file path for `subpath`, as resolved in the `exports` object. + /// + /// Verifies the file exists relative to `packageRoot`. Instances of `*` will + /// be replaced with `patternMatch`. + /// + /// `subpath` and `packageRoot` are native paths, and `patternMatch` is a URL + /// path. + /// + /// Implementation of `PACKAGE_TARGET_RESOLVE` from the [Resolution Algorithm + /// Specification](https://nodejs.org/api/esm.html#resolution-algorithm-specification). + String? _packageTargetResolve( + String? subpath, Object exports, String packageRoot, + [String? patternMatch]) { + switch (exports) { + case String string when !string.startsWith('./'): + throw "Export '$string' must be a path relative to the package root at '$packageRoot'."; + case String string when patternMatch != null: + var replaced = p.fromUri(string.replaceFirst('*', patternMatch)); + var path = p.normalize(p.join(packageRoot, replaced)); + return fileExists(path) ? path : null; + case String string: + return p.join(packageRoot, p.fromUri(string)); + case Map map: + for (var (key, value) in map.pairs) { + if (!const {'sass', 'style', 'default'}.contains(key)) continue; + if (value == null) continue; + if (_packageTargetResolve( + subpath, value as Object, packageRoot, patternMatch) + case var result?) { + return result; + } + } + return null; + + case []: + return null; + + case List array: + for (var value in array) { + if (value == null) continue; + if (_packageTargetResolve( + subpath, value as Object, packageRoot, patternMatch) + case var result?) { + return result; + } + } + + return null; + + default: + throw "Invalid 'exports' value $exports in " + "${p.join(packageRoot, 'package.json')}."; + } + } + + /// Returns a path to a package's export without a subpath. + Object? _getMainExport(Object exports) { + return switch (exports) { + String string => string, + List list => list, + Map map + when !map.keys.any((key) => key.startsWith('.')) => + map, + {'.': var export?} => export, + _ => null + }; + } + + /// Returns a list of all possible variations of `subpath` with extensions and + /// partials. + /// + /// If there is no subpath, returns a single `null` value, which is used in + /// `_nodePackageExportsResolve` to denote the main package export. + List _exportsToCheck(String? subpath, {bool addIndex = false}) { + var paths = []; + + if (subpath == null && addIndex) { + subpath = 'index'; + } else if (subpath != null && addIndex) { + subpath = p.join(subpath, 'index'); + } + if (subpath == null) return [null]; + + if (_validExtensions.contains(p.url.extension(subpath))) { + paths.add(subpath); + } else { + paths.addAll([ + '$subpath.scss', + '$subpath.sass', + '$subpath.css', + ]); + } + var basename = p.basename(subpath); + var dirname = p.dirname(subpath); + + if (basename.startsWith('_')) return paths; + + return [ + ...paths, + for (var path in paths) + if (dirname == '.') + '_${p.basename(path)}' + else + p.join(dirname, '_${p.basename(path)}') + ]; + } +} + +/// The set of file extensions that Sass can parse. +/// +/// `NodePackageImporter` will only resolve files with these extensions, and +/// uses these extensions to check for matches if no extension is provided in +/// the Url to canonicalize. +const _validExtensions = {'.scss', '.sass', '.css'}; diff --git a/lib/src/importer/package.dart b/lib/src/importer/package.dart index 21f41509f..39d09ac63 100644 --- a/lib/src/importer/package.dart +++ b/lib/src/importer/package.dart @@ -7,12 +7,6 @@ import 'package:package_config/package_config_types.dart'; import '../importer.dart'; -/// A filesystem importer to use when resolving the results of `package:` URLs. -/// -/// This allows us to avoid duplicating the logic for choosing an extension and -/// looking for partials. -final _filesystemImporter = FilesystemImporter('.'); - /// An importer that loads stylesheets from `package:` imports. /// /// {@category Importer} @@ -29,7 +23,7 @@ class PackageImporter extends Importer { PackageImporter(PackageConfig packageConfig) : _packageConfig = packageConfig; Uri? canonicalize(Uri url) { - if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); + if (url.scheme == 'file') return FilesystemImporter.cwd.canonicalize(url); if (url.scheme != 'package') return null; var resolved = _packageConfig.resolve(url); @@ -39,17 +33,18 @@ class PackageImporter extends Importer { throw "Unsupported URL $resolved."; } - return _filesystemImporter.canonicalize(resolved); + return FilesystemImporter.cwd.canonicalize(resolved); } - ImporterResult? load(Uri url) => _filesystemImporter.load(url); + ImporterResult? load(Uri url) => FilesystemImporter.cwd.load(url); DateTime modificationTime(Uri url) => - _filesystemImporter.modificationTime(url); + FilesystemImporter.cwd.modificationTime(url); bool couldCanonicalize(Uri url, Uri canonicalUrl) => (url.scheme == 'file' || url.scheme == 'package' || url.scheme == '') && - _filesystemImporter.couldCanonicalize(Uri(path: url.path), canonicalUrl); + FilesystemImporter.cwd + .couldCanonicalize(Uri(path: url.path), canonicalUrl); String toString() => "package:..."; } diff --git a/lib/src/js.dart b/lib/src/js.dart index 92ab23f66..3246fc743 100644 --- a/lib/src/js.dart +++ b/lib/src/js.dart @@ -51,6 +51,7 @@ void main() { silent: JSLogger( warn: allowInteropNamed('sass.Logger.silent.warn', (_, __) {}), debug: allowInteropNamed('sass.Logger.silent.debug', (_, __) {}))); + exports.NodePackageImporter = nodePackageImporterClass; exports.info = "dart-sass\t${const String.fromEnvironment('version')}\t(Sass Compiler)\t" diff --git a/lib/src/js/compile.dart b/lib/src/js/compile.dart index b0a192a9e..0a2c0c9b0 100644 --- a/lib/src/js/compile.dart +++ b/lib/src/js/compile.dart @@ -6,6 +6,7 @@ import 'package:cli_pkg/js.dart'; import 'package:node_interop/js.dart'; import 'package:node_interop/util.dart' hide futureToPromise; import 'package:term_glyph/term_glyph.dart' as glyph; +import 'package:path/path.dart' as p; import '../../sass.dart'; import '../importer/no_op.dart'; @@ -13,6 +14,7 @@ import '../importer/js_to_dart/async.dart'; import '../importer/js_to_dart/async_file.dart'; import '../importer/js_to_dart/file.dart'; import '../importer/js_to_dart/sync.dart'; +import '../importer/node_package.dart'; import '../io.dart'; import '../logger/js_to_dart.dart'; import '../util/nullable.dart'; @@ -20,6 +22,7 @@ import 'compile_options.dart'; import 'compile_result.dart'; import 'exception.dart'; import 'importer.dart'; +import 'reflection.dart'; import 'utils.dart'; /// The JS API `compile` function. @@ -182,6 +185,8 @@ OutputStyle _parseOutputStyle(String? style) => switch (style) { /// Converts [importer] into an [AsyncImporter] that can be used with /// [compileAsync] or [compileStringAsync]. AsyncImporter _parseAsyncImporter(Object? importer) { + if (importer is NodePackageImporter) return importer; + if (importer == null) jsThrow(JsError("Importers may not be null.")); importer as JSImporter; @@ -207,6 +212,8 @@ AsyncImporter _parseAsyncImporter(Object? importer) { /// Converts [importer] into a synchronous [Importer]. Importer _parseImporter(Object? importer) { + if (importer is NodePackageImporter) return importer; + if (importer == null) jsThrow(JsError("Importers may not be null.")); importer as JSImporter; @@ -321,3 +328,16 @@ List _parseFunctions(Object? functions, {bool asynch = false}) { }); return result; } + +/// The exported `NodePackageImporter` class that can be added to the +/// `importers` option to enable loading `pkg:` URLs from `node_modules`. +final JSClass nodePackageImporterClass = () { + var jsClass = createJSClass( + 'sass.NodePackageImporter', + (Object self, [String? entryPointDirectory]) => NodePackageImporter( + entryPointDirectory ?? + (requireMainFilename != null + ? p.dirname(requireMainFilename!) + : null))); + return jsClass; +}(); diff --git a/lib/src/js/exports.dart b/lib/src/js/exports.dart index 3cf5bb7a5..9a45268a8 100644 --- a/lib/src/js/exports.dart +++ b/lib/src/js/exports.dart @@ -26,6 +26,7 @@ class Exports { external set info(String info); external set Exception(JSClass function); external set Logger(LoggerNamespace namespace); + external set NodePackageImporter(JSClass function); // Value APIs external set Value(JSClass function); diff --git a/lib/src/js/legacy.dart b/lib/src/js/legacy.dart index 5c5ad533a..ed4ba7584 100644 --- a/lib/src/js/legacy.dart +++ b/lib/src/js/legacy.dart @@ -10,6 +10,9 @@ import 'dart:typed_data'; import 'package:cli_pkg/js.dart'; import 'package:node_interop/js.dart'; import 'package:path/path.dart' as p; +import '../async_import_cache.dart'; +import '../import_cache.dart'; +import '../importer/node_package.dart'; import '../callable.dart'; import '../compile.dart'; @@ -76,6 +79,7 @@ Future _renderAsync(RenderOptions options) async { if (options.data case var data?) { result = await compileStringAsync(data, nodeImporter: _parseImporter(options, start), + importCache: _parsePackageImportersAsync(options, start), functions: _parseFunctions(options, start, asynch: true), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), @@ -92,6 +96,7 @@ Future _renderAsync(RenderOptions options) async { } else if (file != null) { result = await compileAsync(file, nodeImporter: _parseImporter(options, start), + importCache: _parsePackageImportersAsync(options, start), functions: _parseFunctions(options, start, asynch: true), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), @@ -129,6 +134,7 @@ RenderResult renderSync(RenderOptions options) { if (options.data case var data?) { result = compileString(data, nodeImporter: _parseImporter(options, start), + importCache: _parsePackageImporters(options, start), functions: _parseFunctions(options, start).cast(), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), @@ -145,6 +151,7 @@ RenderResult renderSync(RenderOptions options) { } else if (file != null) { result = compile(file, nodeImporter: _parseImporter(options, start), + importCache: _parsePackageImporters(options, start), functions: _parseFunctions(options, start).cast(), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), @@ -289,6 +296,23 @@ NodeImporter _parseImporter(RenderOptions options, DateTime start) { return NodeImporter(contextOptions, includePaths, importers); } +/// Creates an [AsyncImportCache] for Package Importers. +AsyncImportCache? _parsePackageImportersAsync( + RenderOptions options, DateTime start) { + if (options.pkgImporter is NodePackageImporter) { + return AsyncImportCache.only([options.pkgImporter!]); + } + return null; +} + +/// Creates an [ImportCache] for Package Importers. +ImportCache? _parsePackageImporters(RenderOptions options, DateTime start) { + if (options.pkgImporter is NodePackageImporter) { + return ImportCache.only([options.pkgImporter!]); + } + return null; +} + /// Creates the [RenderContextOptions] for the `this` context in which custom /// functions and importers will be evaluated. RenderContextOptions _contextOptions(RenderOptions options, DateTime start) { diff --git a/lib/src/js/legacy/render_options.dart b/lib/src/js/legacy/render_options.dart index 3357166de..ac8cc61b8 100644 --- a/lib/src/js/legacy/render_options.dart +++ b/lib/src/js/legacy/render_options.dart @@ -4,6 +4,7 @@ import 'package:js/js.dart'; +import '../../importer/node_package.dart'; import '../logger.dart'; import 'fiber.dart'; @@ -13,6 +14,7 @@ class RenderOptions { external String? get file; external String? get data; external Object? get importer; + external NodePackageImporter? get pkgImporter; external Object? get functions; external List? get includePaths; external bool? get indentedSyntax; @@ -36,6 +38,7 @@ class RenderOptions { {String? file, String? data, Object? importer, + NodePackageImporter? pkgImporter, Object? functions, List? includePaths, bool? indentedSyntax, diff --git a/lib/src/js/utils.dart b/lib/src/js/utils.dart index 687484c9a..08fdd8f6b 100644 --- a/lib/src/js/utils.dart +++ b/lib/src/js/utils.dart @@ -233,3 +233,7 @@ Syntax parseSyntax(String? syntax) => switch (syntax) { 'css' => Syntax.css, _ => jsThrow(JsError('Unknown syntax "$syntax".')) }; + +/// The value of require.main.filename +@JS("require.main.filename") +external String? get requireMainFilename; diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index 7d75430f7..26679cd8e 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -337,9 +337,10 @@ final class _EvaluateVisitor Logger? logger, bool quietDeps = false, bool sourceMap = false}) - : _importCache = nodeImporter == null - ? importCache ?? AsyncImportCache.none(logger: logger) - : null, + : _importCache = importCache ?? + (nodeImporter == null + ? AsyncImportCache.none(logger: logger) + : null), _nodeImporter = nodeImporter, _logger = logger ?? const Logger.stderr(), _quietDeps = quietDeps, @@ -1706,7 +1707,9 @@ final class _EvaluateVisitor return (stylesheet, importer: importer, isDependency: isDependency); } } - } else { + } + + if (_nodeImporter != null) { if (await _importLikeNode( url, baseUrl ?? _stylesheet.span.sourceUrl, forImport) case var result?) { diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 37c2e0c08..096ae21fe 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: 71dcf1747eb45036d3c1b5b57bd0cd5dbe6b8e14 +// Checksum: 7351193aa9229e1434c09a2cbc9fa596cd924901 // // ignore_for_file: unused_import @@ -345,9 +345,8 @@ final class _EvaluateVisitor Logger? logger, bool quietDeps = false, bool sourceMap = false}) - : _importCache = nodeImporter == null - ? importCache ?? ImportCache.none(logger: logger) - : null, + : _importCache = importCache ?? + (nodeImporter == null ? ImportCache.none(logger: logger) : null), _nodeImporter = nodeImporter, _logger = logger ?? const Logger.stderr(), _quietDeps = quietDeps, @@ -1702,7 +1701,9 @@ final class _EvaluateVisitor return (stylesheet, importer: importer, isDependency: isDependency); } } - } else { + } + + if (_nodeImporter != null) { if (_importLikeNode( url, baseUrl ?? _stylesheet.span.sourceUrl, forImport) case var result?) { diff --git a/pubspec.yaml b/pubspec.yaml index f14d6b822..eb90882c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: args: ^2.0.0 async: ^2.5.0 charcode: ^1.2.0 - cli_pkg: ^2.7.0 + cli_pkg: ^2.7.1 cli_repl: ^0.2.1 collection: ^1.16.0 http: "^1.1.0"