Skip to content

Commit

Permalink
Expose the containing URL to importers under some circumstances
Browse files Browse the repository at this point in the history
Closes #1946
  • Loading branch information
nex3 committed Sep 15, 2023
1 parent 5c31d1f commit d85091c
Show file tree
Hide file tree
Showing 14 changed files with 270 additions and 67 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ jobs:
sass_spec_js_embedded:
name: 'JS API Tests | Embedded | Node ${{ matrix.node-version }} | ${{ matrix.os }}'
runs-on: ${{ matrix.os }}-latest
if: "github.event_name != 'pull_request' || !contains(github.event.pull_request.body, 'skip sass-embedded')"

strategy:
fail-fast: false
Expand Down
76 changes: 47 additions & 29 deletions lib/src/async_import_cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ final class AsyncImportCache {

/// Canonicalizes [url] according to one of this cache's importers.
///
/// The [baseUrl] should be the canonical URL of the stylesheet that contains
/// the load, if it exists.
///
/// Returns the importer that was used to canonicalize [url], the canonical
/// URL, and the URL that was passed to the importer (which may be resolved
/// relative to [baseUrl] if it's passed).
Expand All @@ -139,33 +142,30 @@ final class AsyncImportCache {
if (isBrowser &&
(baseImporter == null || baseImporter is NoOpImporter) &&
_importers.isEmpty) {
throw "Custom importers are required to load stylesheets when compiling in the browser.";
throw "Custom importers are required to load stylesheets when compiling "
"in the browser.";
}

if (baseImporter != null && url.scheme == '') {
var relativeResult = await putIfAbsentAsync(_relativeCanonicalizeCache, (
url,
forImport: forImport,
baseImporter: baseImporter,
baseUrl: baseUrl
), () async {
var resolvedUrl = baseUrl?.resolveUri(url) ?? url;
if (await _canonicalize(baseImporter, resolvedUrl, forImport)
case var canonicalUrl?) {
return (baseImporter, canonicalUrl, originalUrl: resolvedUrl);
} else {
return null;
}
});
var relativeResult = await putIfAbsentAsync(
_relativeCanonicalizeCache,
(
url,
forImport: forImport,
baseImporter: baseImporter,
baseUrl: baseUrl
),
() => _canonicalize(baseImporter, baseUrl?.resolveUri(url) ?? url,
baseUrl, forImport));
if (relativeResult != null) return relativeResult;
}

return await putIfAbsentAsync(
_canonicalizeCache, (url, forImport: forImport), () async {
for (var importer in _importers) {
if (await _canonicalize(importer, url, forImport)
case var canonicalUrl?) {
return (importer, canonicalUrl, originalUrl: url);
if (await _canonicalize(importer, url, baseUrl, forImport)
case var result?) {
return result;
}
}

Expand All @@ -175,18 +175,36 @@ final class AsyncImportCache {

/// Calls [importer.canonicalize] and prints a deprecation warning if it
/// returns a relative URL.
Future<Uri?> _canonicalize(
AsyncImporter importer, Uri url, bool forImport) async {
var result = await (forImport
? inImportRule(() => importer.canonicalize(url))
: importer.canonicalize(url));
if (result?.scheme == '') {
_logger.warnForDeprecation(Deprecation.relativeCanonical, """
Importer $importer canonicalized $url to $result.
Relative canonical URLs are deprecated and will eventually be disallowed.
""");
///
/// If [resolveUrl] is `true`, this resolves [url] relative to [baseUrl]
/// before passing it to [importer].
Future<AsyncCanonicalizeResult?> _canonicalize(
AsyncImporter importer, Uri url, Uri? baseUrl, bool forImport,
{bool resolveUrl = false}) async {
var resolved =
resolveUrl && baseUrl != null ? baseUrl.resolveUri(url) : url;
var canonicalize = forImport
? () => inImportRule(() => importer.canonicalize(resolved))
: () => importer.canonicalize(resolved);

var passContainingUrl = baseUrl != null &&
(url.scheme == '' || await importer.isNonCanonicalScheme(url.scheme));
var result = await withContainingUrl(
passContainingUrl ? baseUrl : null, canonicalize);
if (result == null) return null;

if (result.scheme == '') {
_logger.warnForDeprecation(
Deprecation.relativeCanonical,
"Importer $importer canonicalized $resolved to $result.\n"
"Relative canonical URLs are deprecated and will eventually be "
"disallowed.");
} else if (await importer.isNonCanonicalScheme(result.scheme)) {
throw "Importer $importer canonicalized $resolved to $result, which "
"uses a scheme declared as non-canonical.";
}
return result;

return (importer, result, originalUrl: resolved);
}

/// Tries to import [url] using one of this cache's importers.
Expand Down
74 changes: 46 additions & 28 deletions lib/src/import_cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// DO NOT EDIT. This file was generated from async_import_cache.dart.
// See tool/grind/synchronize.dart for details.
//
// Checksum: 3e4cae79c03ce2af6626b1822f1468523b401e90
// Checksum: ff52307a3bc93358ddc46f1e76120894fa3e071f
//
// ignore_for_file: unused_import

Expand Down Expand Up @@ -124,6 +124,9 @@ final class ImportCache {

/// Canonicalizes [url] according to one of this cache's importers.
///
/// The [baseUrl] should be the canonical URL of the stylesheet that contains
/// the load, if it exists.
///
/// Returns the importer that was used to canonicalize [url], the canonical
/// URL, and the URL that was passed to the importer (which may be resolved
/// relative to [baseUrl] if it's passed).
Expand All @@ -139,31 +142,27 @@ final class ImportCache {
if (isBrowser &&
(baseImporter == null || baseImporter is NoOpImporter) &&
_importers.isEmpty) {
throw "Custom importers are required to load stylesheets when compiling in the browser.";
throw "Custom importers are required to load stylesheets when compiling "
"in the browser.";
}

if (baseImporter != null && url.scheme == '') {
var relativeResult = _relativeCanonicalizeCache.putIfAbsent((
url,
forImport: forImport,
baseImporter: baseImporter,
baseUrl: baseUrl
), () {
var resolvedUrl = baseUrl?.resolveUri(url) ?? url;
if (_canonicalize(baseImporter, resolvedUrl, forImport)
case var canonicalUrl?) {
return (baseImporter, canonicalUrl, originalUrl: resolvedUrl);
} else {
return null;
}
});
var relativeResult = _relativeCanonicalizeCache.putIfAbsent(
(
url,
forImport: forImport,
baseImporter: baseImporter,
baseUrl: baseUrl
),
() => _canonicalize(baseImporter, baseUrl?.resolveUri(url) ?? url,
baseUrl, forImport));
if (relativeResult != null) return relativeResult;
}

return _canonicalizeCache.putIfAbsent((url, forImport: forImport), () {
for (var importer in _importers) {
if (_canonicalize(importer, url, forImport) case var canonicalUrl?) {
return (importer, canonicalUrl, originalUrl: url);
if (_canonicalize(importer, url, baseUrl, forImport) case var result?) {
return result;
}
}

Expand All @@ -173,17 +172,36 @@ final class ImportCache {

/// Calls [importer.canonicalize] and prints a deprecation warning if it
/// returns a relative URL.
Uri? _canonicalize(Importer importer, Uri url, bool forImport) {
var result = (forImport
? inImportRule(() => importer.canonicalize(url))
: importer.canonicalize(url));
if (result?.scheme == '') {
_logger.warnForDeprecation(Deprecation.relativeCanonical, """
Importer $importer canonicalized $url to $result.
Relative canonical URLs are deprecated and will eventually be disallowed.
""");
///
/// If [resolveUrl] is `true`, this resolves [url] relative to [baseUrl]
/// before passing it to [importer].
CanonicalizeResult? _canonicalize(
Importer importer, Uri url, Uri? baseUrl, bool forImport,
{bool resolveUrl = false}) {
var resolved =
resolveUrl && baseUrl != null ? baseUrl.resolveUri(url) : url;
var canonicalize = forImport
? () => inImportRule(() => importer.canonicalize(resolved))
: () => importer.canonicalize(resolved);

var passContainingUrl = baseUrl != null &&
(url.scheme == '' || importer.isNonCanonicalScheme(url.scheme));
var result =
withContainingUrl(passContainingUrl ? baseUrl : null, canonicalize);
if (result == null) return null;

if (result.scheme == '') {
_logger.warnForDeprecation(
Deprecation.relativeCanonical,
"Importer $importer canonicalized $resolved to $result.\n"
"Relative canonical URLs are deprecated and will eventually be "
"disallowed.");
} else if (importer.isNonCanonicalScheme(result.scheme)) {
throw "Importer $importer canonicalized $resolved to $result, which "
"uses a scheme declared as non-canonical.";
}
return result;

return (importer, result, originalUrl: resolved);
}

/// Tries to import [url] using one of this cache's importers.
Expand Down
2 changes: 2 additions & 0 deletions lib/src/importer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,6 @@ abstract class Importer extends AsyncImporter {
DateTime modificationTime(Uri url) => DateTime.now();

bool couldCanonicalize(Uri url, Uri canonicalUrl) => true;

bool isNonCanonicalScheme(String scheme) => false;
}
27 changes: 27 additions & 0 deletions lib/src/importer/async.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ abstract class AsyncImporter {
@nonVirtual
bool get fromImport => utils.fromImport;

/// The canonical URL of the stylesheet that caused the current [canonicalize]
/// invocation.
///
/// This is only set when the containing stylesheet has a canonical URL, and
/// when the URL being canonicalized is either relative or has a scheme for
/// which [isNonCanonicalScheme] returns `true`. This restriction ensures that
/// canonical URLs are always interpreted the same way regardless of their
/// context.
///
/// Subclasses should only access this from within calls to [canonicalize].
/// Outside of that context, its value is undefined and subject to change.
@protected
@nonVirtual
Uri? get containingUrl => utils.containingUrl;

/// If [url] is recognized by this importer, returns its canonical format.
///
/// Note that canonical URLs *must* be absolute, including a scheme. Returning
Expand Down Expand Up @@ -137,4 +152,16 @@ abstract class AsyncImporter {
/// [url] would actually resolve to [canonicalUrl]. Subclasses are not allowed
/// to return false negatives.
FutureOr<bool> couldCanonicalize(Uri url, Uri canonicalUrl) => true;

/// Returns whether the given URL scheme (without `:`) should be considered
/// "non-canonical" for this importer.
///
/// An importer may not return a URL with a non-canonical scheme from
/// [canonicalize]. In exchange, [containingUrl] is available within
/// [canonicalize] for absolute URLs with non-canonical schemes so that the
/// importer can resolve those URLs differently based on where they're loaded.
///
/// This must always return the same value for the same [scheme]. It is
/// expected to be very efficient.
FutureOr<bool> isNonCanonicalScheme(String scheme) => false;
}
15 changes: 13 additions & 2 deletions lib/src/importer/js_to_dart/async.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ final class JSToDartAsyncImporter extends AsyncImporter {
/// The wrapped load function.
final Object? Function(JSUrl) _load;

JSToDartAsyncImporter(this._canonicalize, this._load);
/// The set of URL schemes that this importer promises never to return from
/// [canonicalize].
final Set<String> _nonCanonicalSchemes;

JSToDartAsyncImporter(this._canonicalize, this._load,
{Iterable<String>? nonCanonicalSchemes})
: _nonCanonicalSchemes = nonCanonicalSchemes == null
? const {}
: Set.unmodifiable(nonCanonicalSchemes);

FutureOr<Uri?> canonicalize(Uri url) async {
var result = wrapJSExceptions(() => _canonicalize(
Expand All @@ -42,7 +50,7 @@ final class JSToDartAsyncImporter extends AsyncImporter {
if (isPromise(result)) result = await promiseToFuture(result as Promise);
if (result == null) return null;

result as NodeImporterResult;
result as JSImporterResult;
var contents = result.contents;
if (!isJsString(contents)) {
jsThrow(ArgumentError.value(contents, 'contents',
Expand All @@ -59,4 +67,7 @@ final class JSToDartAsyncImporter extends AsyncImporter {
syntax: parseSyntax(syntax),
sourceMapUrl: result.sourceMapUrl.andThen(jsToDartUrl));
}

bool isNonCanonicalScheme(String scheme) =>
_nonCanonicalSchemes.contains(scheme);
}
2 changes: 2 additions & 0 deletions lib/src/importer/js_to_dart/file.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@ final class JSToDartFileImporter extends Importer {

bool couldCanonicalize(Uri url, Uri canonicalUrl) =>
_filesystemImporter.couldCanonicalize(url, canonicalUrl);

bool isNonCanonicalSchemes(String scheme) => scheme != 'file';
}
2 changes: 1 addition & 1 deletion lib/src/importer/js_to_dart/sync.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ final class JSToDartImporter extends Importer {
"functions."));
}

result as NodeImporterResult;
result as JSImporterResult;
var contents = result.contents;
if (!isJsString(contents)) {
jsThrow(ArgumentError.value(contents, 'contents',
Expand Down
18 changes: 18 additions & 0 deletions lib/src/importer/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,29 @@ import '../io.dart';
/// removed, at which point we can delete this and have one consistent behavior.
bool get fromImport => Zone.current[#_inImportRule] as bool? ?? false;

/// The URL of the stylesheet that contains the current load.
Uri? get containingUrl => switch (Zone.current[#_containingUrl]) {
null => throw StateError(
"containingUrl may only be accessed within a call to canonicalize()."),
#_none => null,
Uri url => url,
var value => throw StateError(
"Unexpected Zone.current[#_containingUrl] value $value.")
};

/// Runs [callback] in a context where [fromImport] returns `true` and
/// [resolveImportPath] uses `@import` semantics rather than `@use` semantics.
T inImportRule<T>(T callback()) =>
runZoned(callback, zoneValues: {#_inImportRule: true});

/// Runs [callback] in a context where [containingUrl] returns [url].
///
/// If [when] is `false`, runs [callback] without setting [containingUrl].
T withContainingUrl<T>(Uri? url, T callback()) =>
// Use #_none as a sentinel value so we can distinguish a containing URL
// that's set to null from one that's unset at all.
runZoned(callback, zoneValues: {#_containingUrl: url ?? #_none});

/// Resolves an imported path using the same logic as the filesystem importer.
///
/// This tries to fill in extensions and partial prefixes and check for a
Expand Down
4 changes: 2 additions & 2 deletions lib/src/js/compile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ OutputStyle _parseOutputStyle(String? style) => switch (style) {
AsyncImporter _parseAsyncImporter(Object? importer) {
if (importer == null) jsThrow(JsError("Importers may not be null."));

importer as NodeImporter;
importer as JSImporter;
var canonicalize = importer.canonicalize;
var load = importer.load;
if (importer.findFileUrl case var findFileUrl?) {
Expand All @@ -208,7 +208,7 @@ AsyncImporter _parseAsyncImporter(Object? importer) {
Importer _parseImporter(Object? importer) {
if (importer == null) jsThrow(JsError("Importers may not be null."));

importer as NodeImporter;
importer as JSImporter;
var canonicalize = importer.canonicalize;
var load = importer.load;
if (importer.findFileUrl case var findFileUrl?) {
Expand Down
2 changes: 1 addition & 1 deletion lib/src/js/compile_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ class CompileOptions {
class CompileStringOptions extends CompileOptions {
external String? get syntax;
external JSUrl? get url;
external NodeImporter? get importer;
external JSImporter? get importer;
}
Loading

0 comments on commit d85091c

Please sign in to comment.