Skip to content

Commit 13c9fb3

Browse files
authoredSep 18, 2023
Expose the containing URL to importers under some circumstances (#2083)
Closes #1946
1 parent 69f1847 commit 13c9fb3

22 files changed

+574
-98
lines changed
 

‎.github/workflows/ci.yml

+1
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ jobs:
137137
sass_spec_js_embedded:
138138
name: 'JS API Tests | Embedded | Node ${{ matrix.node-version }} | ${{ matrix.os }}'
139139
runs-on: ${{ matrix.os }}-latest
140+
if: "github.event_name != 'pull_request' || !contains(github.event.pull_request.body, 'skip sass-embedded')"
140141

141142
strategy:
142143
fail-fast: false

‎lib/src/async_import_cache.dart

+47-29
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ final class AsyncImportCache {
122122

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

145149
if (baseImporter != null && url.scheme == '') {
146-
var relativeResult = await putIfAbsentAsync(_relativeCanonicalizeCache, (
147-
url,
148-
forImport: forImport,
149-
baseImporter: baseImporter,
150-
baseUrl: baseUrl
151-
), () async {
152-
var resolvedUrl = baseUrl?.resolveUri(url) ?? url;
153-
if (await _canonicalize(baseImporter, resolvedUrl, forImport)
154-
case var canonicalUrl?) {
155-
return (baseImporter, canonicalUrl, originalUrl: resolvedUrl);
156-
} else {
157-
return null;
158-
}
159-
});
150+
var relativeResult = await putIfAbsentAsync(
151+
_relativeCanonicalizeCache,
152+
(
153+
url,
154+
forImport: forImport,
155+
baseImporter: baseImporter,
156+
baseUrl: baseUrl
157+
),
158+
() => _canonicalize(baseImporter, baseUrl?.resolveUri(url) ?? url,
159+
baseUrl, forImport));
160160
if (relativeResult != null) return relativeResult;
161161
}
162162

163163
return await putIfAbsentAsync(
164164
_canonicalizeCache, (url, forImport: forImport), () async {
165165
for (var importer in _importers) {
166-
if (await _canonicalize(importer, url, forImport)
167-
case var canonicalUrl?) {
168-
return (importer, canonicalUrl, originalUrl: url);
166+
if (await _canonicalize(importer, url, baseUrl, forImport)
167+
case var result?) {
168+
return result;
169169
}
170170
}
171171

@@ -175,18 +175,36 @@ final class AsyncImportCache {
175175

176176
/// Calls [importer.canonicalize] and prints a deprecation warning if it
177177
/// returns a relative URL.
178-
Future<Uri?> _canonicalize(
179-
AsyncImporter importer, Uri url, bool forImport) async {
180-
var result = await (forImport
181-
? inImportRule(() => importer.canonicalize(url))
182-
: importer.canonicalize(url));
183-
if (result?.scheme == '') {
184-
_logger.warnForDeprecation(Deprecation.relativeCanonical, """
185-
Importer $importer canonicalized $url to $result.
186-
Relative canonical URLs are deprecated and will eventually be disallowed.
187-
""");
178+
///
179+
/// If [resolveUrl] is `true`, this resolves [url] relative to [baseUrl]
180+
/// before passing it to [importer].
181+
Future<AsyncCanonicalizeResult?> _canonicalize(
182+
AsyncImporter importer, Uri url, Uri? baseUrl, bool forImport,
183+
{bool resolveUrl = false}) async {
184+
var resolved =
185+
resolveUrl && baseUrl != null ? baseUrl.resolveUri(url) : url;
186+
var canonicalize = forImport
187+
? () => inImportRule(() => importer.canonicalize(resolved))
188+
: () => importer.canonicalize(resolved);
189+
190+
var passContainingUrl = baseUrl != null &&
191+
(url.scheme == '' || await importer.isNonCanonicalScheme(url.scheme));
192+
var result = await withContainingUrl(
193+
passContainingUrl ? baseUrl : null, canonicalize);
194+
if (result == null) return null;
195+
196+
if (result.scheme == '') {
197+
_logger.warnForDeprecation(
198+
Deprecation.relativeCanonical,
199+
"Importer $importer canonicalized $resolved to $result.\n"
200+
"Relative canonical URLs are deprecated and will eventually be "
201+
"disallowed.");
202+
} else if (await importer.isNonCanonicalScheme(result.scheme)) {
203+
throw "Importer $importer canonicalized $resolved to $result, which "
204+
"uses a scheme declared as non-canonical.";
188205
}
189-
return result;
206+
207+
return (importer, result, originalUrl: resolved);
190208
}
191209

192210
/// Tries to import [url] using one of this cache's importers.

‎lib/src/embedded/dispatcher.dart

+15-4
Original file line numberDiff line numberDiff line change
@@ -206,19 +206,32 @@ final class Dispatcher {
206206
InboundMessage_CompileRequest_Importer importer) {
207207
switch (importer.whichImporter()) {
208208
case InboundMessage_CompileRequest_Importer_Importer.path:
209+
_checkNoNonCanonicalScheme(importer);
209210
return sass.FilesystemImporter(importer.path);
210211

211212
case InboundMessage_CompileRequest_Importer_Importer.importerId:
212-
return HostImporter(this, importer.importerId);
213+
return HostImporter(
214+
this, importer.importerId, importer.nonCanonicalScheme);
213215

214216
case InboundMessage_CompileRequest_Importer_Importer.fileImporterId:
217+
_checkNoNonCanonicalScheme(importer);
215218
return FileImporter(this, importer.fileImporterId);
216219

217220
case InboundMessage_CompileRequest_Importer_Importer.notSet:
221+
_checkNoNonCanonicalScheme(importer);
218222
return null;
219223
}
220224
}
221225

226+
/// Throws a [ProtocolError] if [importer] contains one or more
227+
/// `nonCanonicalScheme`s.
228+
void _checkNoNonCanonicalScheme(
229+
InboundMessage_CompileRequest_Importer importer) {
230+
if (importer.nonCanonicalScheme.isEmpty) return;
231+
throw paramsError("Importer.non_canonical_scheme may only be set along "
232+
"with Importer.importer.importer_id");
233+
}
234+
222235
/// Sends [event] to the host.
223236
void sendLog(OutboundMessage_LogEvent event) =>
224237
_send(OutboundMessage()..logEvent = event);
@@ -278,9 +291,7 @@ final class Dispatcher {
278291
InboundMessage_Message.versionRequest =>
279292
throw paramsError("VersionRequest must have compilation ID 0."),
280293
InboundMessage_Message.notSet =>
281-
throw parseError("InboundMessage.message is not set."),
282-
_ =>
283-
throw parseError("Unknown message type: ${message.toDebugString()}")
294+
throw parseError("InboundMessage.message is not set.")
284295
};
285296

286297
if (message.id != _outboundRequestId) {

‎lib/src/embedded/importer/file.dart

+10-5
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ final class FileImporter extends ImporterBase {
2424
Uri? canonicalize(Uri url) {
2525
if (url.scheme == 'file') return _filesystemImporter.canonicalize(url);
2626

27-
var response =
28-
dispatcher.sendFileImportRequest(OutboundMessage_FileImportRequest()
29-
..importerId = _importerId
30-
..url = url.toString()
31-
..fromImport = fromImport);
27+
var request = OutboundMessage_FileImportRequest()
28+
..importerId = _importerId
29+
..url = url.toString()
30+
..fromImport = fromImport;
31+
if (containingUrl case var containingUrl?) {
32+
request.containingUrl = containingUrl.toString();
33+
}
34+
var response = dispatcher.sendFileImportRequest(request);
3235

3336
switch (response.whichResult()) {
3437
case InboundMessage_FileImportResponse_Result.fileUrl:
@@ -49,5 +52,7 @@ final class FileImporter extends ImporterBase {
4952

5053
ImporterResult? load(Uri url) => _filesystemImporter.load(url);
5154

55+
bool isNonCanonicalScheme(String scheme) => scheme != 'file';
56+
5257
String toString() => "FileImporter";
5358
}

‎lib/src/embedded/importer/host.dart

+29-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
// MIT-style license that can be found in the LICENSE file or at
33
// https://opensource.org/licenses/MIT.
44

5+
import '../../exception.dart';
56
import '../../importer.dart';
7+
import '../../importer/utils.dart';
8+
import '../../util/span.dart';
69
import '../dispatcher.dart';
710
import '../embedded_sass.pb.dart' hide SourceSpan;
811
import '../utils.dart';
@@ -13,14 +16,31 @@ final class HostImporter extends ImporterBase {
1316
/// The host-provided ID of the importer to invoke.
1417
final int _importerId;
1518

16-
HostImporter(Dispatcher dispatcher, this._importerId) : super(dispatcher);
19+
/// The set of URL schemes that this importer promises never to return from
20+
/// [canonicalize].
21+
final Set<String> _nonCanonicalSchemes;
22+
23+
HostImporter(Dispatcher dispatcher, this._importerId,
24+
Iterable<String> nonCanonicalSchemes)
25+
: _nonCanonicalSchemes = Set.unmodifiable(nonCanonicalSchemes),
26+
super(dispatcher) {
27+
for (var scheme in _nonCanonicalSchemes) {
28+
if (isValidUrlScheme(scheme)) continue;
29+
throw SassException(
30+
'"$scheme" isn\'t a valid URL scheme (for example "file").',
31+
bogusSpan);
32+
}
33+
}
1734

1835
Uri? canonicalize(Uri url) {
19-
var response =
20-
dispatcher.sendCanonicalizeRequest(OutboundMessage_CanonicalizeRequest()
21-
..importerId = _importerId
22-
..url = url.toString()
23-
..fromImport = fromImport);
36+
var request = OutboundMessage_CanonicalizeRequest()
37+
..importerId = _importerId
38+
..url = url.toString()
39+
..fromImport = fromImport;
40+
if (containingUrl case var containingUrl?) {
41+
request.containingUrl = containingUrl.toString();
42+
}
43+
var response = dispatcher.sendCanonicalizeRequest(request);
2444

2545
return switch (response.whichResult()) {
2646
InboundMessage_CanonicalizeResponse_Result.url =>
@@ -47,5 +67,8 @@ final class HostImporter extends ImporterBase {
4767
};
4868
}
4969

70+
bool isNonCanonicalScheme(String scheme) =>
71+
_nonCanonicalSchemes.contains(scheme);
72+
5073
String toString() => "HostImporter";
5174
}

‎lib/src/import_cache.dart

+46-28
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// DO NOT EDIT. This file was generated from async_import_cache.dart.
66
// See tool/grind/synchronize.dart for details.
77
//
8-
// Checksum: 3e4cae79c03ce2af6626b1822f1468523b401e90
8+
// Checksum: ff52307a3bc93358ddc46f1e76120894fa3e071f
99
//
1010
// ignore_for_file: unused_import
1111

@@ -124,6 +124,9 @@ final class ImportCache {
124124

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

145149
if (baseImporter != null && url.scheme == '') {
146-
var relativeResult = _relativeCanonicalizeCache.putIfAbsent((
147-
url,
148-
forImport: forImport,
149-
baseImporter: baseImporter,
150-
baseUrl: baseUrl
151-
), () {
152-
var resolvedUrl = baseUrl?.resolveUri(url) ?? url;
153-
if (_canonicalize(baseImporter, resolvedUrl, forImport)
154-
case var canonicalUrl?) {
155-
return (baseImporter, canonicalUrl, originalUrl: resolvedUrl);
156-
} else {
157-
return null;
158-
}
159-
});
150+
var relativeResult = _relativeCanonicalizeCache.putIfAbsent(
151+
(
152+
url,
153+
forImport: forImport,
154+
baseImporter: baseImporter,
155+
baseUrl: baseUrl
156+
),
157+
() => _canonicalize(baseImporter, baseUrl?.resolveUri(url) ?? url,
158+
baseUrl, forImport));
160159
if (relativeResult != null) return relativeResult;
161160
}
162161

163162
return _canonicalizeCache.putIfAbsent((url, forImport: forImport), () {
164163
for (var importer in _importers) {
165-
if (_canonicalize(importer, url, forImport) case var canonicalUrl?) {
166-
return (importer, canonicalUrl, originalUrl: url);
164+
if (_canonicalize(importer, url, baseUrl, forImport) case var result?) {
165+
return result;
167166
}
168167
}
169168

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

174173
/// Calls [importer.canonicalize] and prints a deprecation warning if it
175174
/// returns a relative URL.
176-
Uri? _canonicalize(Importer importer, Uri url, bool forImport) {
177-
var result = (forImport
178-
? inImportRule(() => importer.canonicalize(url))
179-
: importer.canonicalize(url));
180-
if (result?.scheme == '') {
181-
_logger.warnForDeprecation(Deprecation.relativeCanonical, """
182-
Importer $importer canonicalized $url to $result.
183-
Relative canonical URLs are deprecated and will eventually be disallowed.
184-
""");
175+
///
176+
/// If [resolveUrl] is `true`, this resolves [url] relative to [baseUrl]
177+
/// before passing it to [importer].
178+
CanonicalizeResult? _canonicalize(
179+
Importer importer, Uri url, Uri? baseUrl, bool forImport,
180+
{bool resolveUrl = false}) {
181+
var resolved =
182+
resolveUrl && baseUrl != null ? baseUrl.resolveUri(url) : url;
183+
var canonicalize = forImport
184+
? () => inImportRule(() => importer.canonicalize(resolved))
185+
: () => importer.canonicalize(resolved);
186+
187+
var passContainingUrl = baseUrl != null &&
188+
(url.scheme == '' || importer.isNonCanonicalScheme(url.scheme));
189+
var result =
190+
withContainingUrl(passContainingUrl ? baseUrl : null, canonicalize);
191+
if (result == null) return null;
192+
193+
if (result.scheme == '') {
194+
_logger.warnForDeprecation(
195+
Deprecation.relativeCanonical,
196+
"Importer $importer canonicalized $resolved to $result.\n"
197+
"Relative canonical URLs are deprecated and will eventually be "
198+
"disallowed.");
199+
} else if (importer.isNonCanonicalScheme(result.scheme)) {
200+
throw "Importer $importer canonicalized $resolved to $result, which "
201+
"uses a scheme declared as non-canonical.";
185202
}
186-
return result;
203+
204+
return (importer, result, originalUrl: resolved);
187205
}
188206

189207
/// Tries to import [url] using one of this cache's importers.

‎lib/src/importer.dart

+2
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,6 @@ abstract class Importer extends AsyncImporter {
4040
DateTime modificationTime(Uri url) => DateTime.now();
4141

4242
bool couldCanonicalize(Uri url, Uri canonicalUrl) => true;
43+
44+
bool isNonCanonicalScheme(String scheme) => false;
4345
}

‎lib/src/importer/async.dart

+27
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ abstract class AsyncImporter {
4141
@nonVirtual
4242
bool get fromImport => utils.fromImport;
4343

44+
/// The canonical URL of the stylesheet that caused the current [canonicalize]
45+
/// invocation.
46+
///
47+
/// This is only set when the containing stylesheet has a canonical URL, and
48+
/// when the URL being canonicalized is either relative or has a scheme for
49+
/// which [isNonCanonicalScheme] returns `true`. This restriction ensures that
50+
/// canonical URLs are always interpreted the same way regardless of their
51+
/// context.
52+
///
53+
/// Subclasses should only access this from within calls to [canonicalize].
54+
/// Outside of that context, its value is undefined and subject to change.
55+
@protected
56+
@nonVirtual
57+
Uri? get containingUrl => utils.containingUrl;
58+
4459
/// If [url] is recognized by this importer, returns its canonical format.
4560
///
4661
/// Note that canonical URLs *must* be absolute, including a scheme. Returning
@@ -137,4 +152,16 @@ abstract class AsyncImporter {
137152
/// [url] would actually resolve to [canonicalUrl]. Subclasses are not allowed
138153
/// to return false negatives.
139154
FutureOr<bool> couldCanonicalize(Uri url, Uri canonicalUrl) => true;
155+
156+
/// Returns whether the given URL scheme (without `:`) should be considered
157+
/// "non-canonical" for this importer.
158+
///
159+
/// An importer may not return a URL with a non-canonical scheme from
160+
/// [canonicalize]. In exchange, [containingUrl] is available within
161+
/// [canonicalize] for absolute URLs with non-canonical schemes so that the
162+
/// importer can resolve those URLs differently based on where they're loaded.
163+
///
164+
/// This must always return the same value for the same [scheme]. It is
165+
/// expected to be very efficient.
166+
FutureOr<bool> isNonCanonicalScheme(String scheme) => false;
140167
}

‎lib/src/importer/js_to_dart/async.dart

+21-4
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,35 @@ import '../../js/utils.dart';
1414
import '../../util/nullable.dart';
1515
import '../async.dart';
1616
import '../result.dart';
17+
import 'utils.dart';
1718

1819
/// A wrapper for a potentially-asynchronous JS API importer that exposes it as
1920
/// a Dart [AsyncImporter].
2021
final class JSToDartAsyncImporter extends AsyncImporter {
2122
/// The wrapped canonicalize function.
22-
final Object? Function(String, CanonicalizeOptions) _canonicalize;
23+
final Object? Function(String, CanonicalizeContext) _canonicalize;
2324

2425
/// The wrapped load function.
2526
final Object? Function(JSUrl) _load;
2627

27-
JSToDartAsyncImporter(this._canonicalize, this._load);
28+
/// The set of URL schemes that this importer promises never to return from
29+
/// [canonicalize].
30+
final Set<String> _nonCanonicalSchemes;
31+
32+
JSToDartAsyncImporter(
33+
this._canonicalize, this._load, Iterable<String>? nonCanonicalSchemes)
34+
: _nonCanonicalSchemes = nonCanonicalSchemes == null
35+
? const {}
36+
: Set.unmodifiable(nonCanonicalSchemes) {
37+
_nonCanonicalSchemes.forEach(validateUrlScheme);
38+
}
2839

2940
FutureOr<Uri?> canonicalize(Uri url) async {
3041
var result = wrapJSExceptions(() => _canonicalize(
31-
url.toString(), CanonicalizeOptions(fromImport: fromImport)));
42+
url.toString(),
43+
CanonicalizeContext(
44+
fromImport: fromImport,
45+
containingUrl: containingUrl.andThen(dartToJSUrl))));
3246
if (isPromise(result)) result = await promiseToFuture(result as Promise);
3347
if (result == null) return null;
3448

@@ -42,7 +56,7 @@ final class JSToDartAsyncImporter extends AsyncImporter {
4256
if (isPromise(result)) result = await promiseToFuture(result as Promise);
4357
if (result == null) return null;
4458

45-
result as NodeImporterResult;
59+
result as JSImporterResult;
4660
var contents = result.contents;
4761
if (!isJsString(contents)) {
4862
jsThrow(ArgumentError.value(contents, 'contents',
@@ -59,4 +73,7 @@ final class JSToDartAsyncImporter extends AsyncImporter {
5973
syntax: parseSyntax(syntax),
6074
sourceMapUrl: result.sourceMapUrl.andThen(jsToDartUrl));
6175
}
76+
77+
bool isNonCanonicalScheme(String scheme) =>
78+
_nonCanonicalSchemes.contains(scheme);
6279
}

‎lib/src/importer/js_to_dart/async_file.dart

+8-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:node_interop/util.dart';
1111
import '../../js/importer.dart';
1212
import '../../js/url.dart';
1313
import '../../js/utils.dart';
14+
import '../../util/nullable.dart';
1415
import '../async.dart';
1516
import '../filesystem.dart';
1617
import '../result.dart';
@@ -26,15 +27,18 @@ final _filesystemImporter = FilesystemImporter('.');
2627
/// it as a Dart [AsyncImporter].
2728
final class JSToDartAsyncFileImporter extends AsyncImporter {
2829
/// The wrapped `findFileUrl` function.
29-
final Object? Function(String, CanonicalizeOptions) _findFileUrl;
30+
final Object? Function(String, CanonicalizeContext) _findFileUrl;
3031

3132
JSToDartAsyncFileImporter(this._findFileUrl);
3233

3334
FutureOr<Uri?> canonicalize(Uri url) async {
3435
if (url.scheme == 'file') return _filesystemImporter.canonicalize(url);
3536

3637
var result = wrapJSExceptions(() => _findFileUrl(
37-
url.toString(), CanonicalizeOptions(fromImport: fromImport)));
38+
url.toString(),
39+
CanonicalizeContext(
40+
fromImport: fromImport,
41+
containingUrl: containingUrl.andThen(dartToJSUrl))));
3842
if (isPromise(result)) result = await promiseToFuture(result as Promise);
3943
if (result == null) return null;
4044
if (!isJSUrl(result)) {
@@ -58,4 +62,6 @@ final class JSToDartAsyncFileImporter extends AsyncImporter {
5862

5963
bool couldCanonicalize(Uri url, Uri canonicalUrl) =>
6064
_filesystemImporter.couldCanonicalize(url, canonicalUrl);
65+
66+
bool isNonCanonicalScheme(String scheme) => scheme != 'file';
6167
}

‎lib/src/importer/js_to_dart/file.dart

+8-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import '../../importer.dart';
99
import '../../js/importer.dart';
1010
import '../../js/url.dart';
1111
import '../../js/utils.dart';
12+
import '../../util/nullable.dart';
1213
import '../utils.dart';
1314

1415
/// A filesystem importer to use for most implementation details of
@@ -21,15 +22,18 @@ final _filesystemImporter = FilesystemImporter('.');
2122
/// it as a Dart [AsyncImporter].
2223
final class JSToDartFileImporter extends Importer {
2324
/// The wrapped `findFileUrl` function.
24-
final Object? Function(String, CanonicalizeOptions) _findFileUrl;
25+
final Object? Function(String, CanonicalizeContext) _findFileUrl;
2526

2627
JSToDartFileImporter(this._findFileUrl);
2728

2829
Uri? canonicalize(Uri url) {
2930
if (url.scheme == 'file') return _filesystemImporter.canonicalize(url);
3031

3132
var result = wrapJSExceptions(() => _findFileUrl(
32-
url.toString(), CanonicalizeOptions(fromImport: fromImport)));
33+
url.toString(),
34+
CanonicalizeContext(
35+
fromImport: fromImport,
36+
containingUrl: containingUrl.andThen(dartToJSUrl))));
3337
if (result == null) return null;
3438

3539
if (isPromise(result)) {
@@ -57,4 +61,6 @@ final class JSToDartFileImporter extends Importer {
5761

5862
bool couldCanonicalize(Uri url, Uri canonicalUrl) =>
5963
_filesystemImporter.couldCanonicalize(url, canonicalUrl);
64+
65+
bool isNonCanonicalScheme(String scheme) => scheme != 'file';
6066
}

‎lib/src/importer/js_to_dart/sync.dart

+21-4
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,35 @@ import '../../js/importer.dart';
1010
import '../../js/url.dart';
1111
import '../../js/utils.dart';
1212
import '../../util/nullable.dart';
13+
import 'utils.dart';
1314

1415
/// A wrapper for a synchronous JS API importer that exposes it as a Dart
1516
/// [Importer].
1617
final class JSToDartImporter extends Importer {
1718
/// The wrapped canonicalize function.
18-
final Object? Function(String, CanonicalizeOptions) _canonicalize;
19+
final Object? Function(String, CanonicalizeContext) _canonicalize;
1920

2021
/// The wrapped load function.
2122
final Object? Function(JSUrl) _load;
2223

23-
JSToDartImporter(this._canonicalize, this._load);
24+
/// The set of URL schemes that this importer promises never to return from
25+
/// [canonicalize].
26+
final Set<String> _nonCanonicalSchemes;
27+
28+
JSToDartImporter(
29+
this._canonicalize, this._load, Iterable<String>? nonCanonicalSchemes)
30+
: _nonCanonicalSchemes = nonCanonicalSchemes == null
31+
? const {}
32+
: Set.unmodifiable(nonCanonicalSchemes) {
33+
_nonCanonicalSchemes.forEach(validateUrlScheme);
34+
}
2435

2536
Uri? canonicalize(Uri url) {
2637
var result = wrapJSExceptions(() => _canonicalize(
27-
url.toString(), CanonicalizeOptions(fromImport: fromImport)));
38+
url.toString(),
39+
CanonicalizeContext(
40+
fromImport: fromImport,
41+
containingUrl: containingUrl.andThen(dartToJSUrl))));
2842
if (result == null) return null;
2943
if (isJSUrl(result)) return jsToDartUrl(result as JSUrl);
3044

@@ -47,7 +61,7 @@ final class JSToDartImporter extends Importer {
4761
"functions."));
4862
}
4963

50-
result as NodeImporterResult;
64+
result as JSImporterResult;
5165
var contents = result.contents;
5266
if (!isJsString(contents)) {
5367
jsThrow(ArgumentError.value(contents, 'contents',
@@ -64,4 +78,7 @@ final class JSToDartImporter extends Importer {
6478
syntax: parseSyntax(syntax),
6579
sourceMapUrl: result.sourceMapUrl.andThen(jsToDartUrl));
6680
}
81+
82+
bool isNonCanonicalScheme(String scheme) =>
83+
_nonCanonicalSchemes.contains(scheme);
6784
}
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2023 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import 'package:node_interop/js.dart';
6+
7+
import '../../js/utils.dart';
8+
import '../utils.dart';
9+
10+
/// Throws a JsError if [scheme] isn't a valid URL scheme.
11+
void validateUrlScheme(String scheme) {
12+
if (!isValidUrlScheme(scheme)) {
13+
jsThrow(
14+
JsError('"$scheme" isn\'t a valid URL scheme (for example "file").'));
15+
}
16+
}

‎lib/src/importer/utils.dart

+24
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,29 @@ import '../io.dart';
1717
/// removed, at which point we can delete this and have one consistent behavior.
1818
bool get fromImport => Zone.current[#_inImportRule] as bool? ?? false;
1919

20+
/// The URL of the stylesheet that contains the current load.
21+
Uri? get containingUrl => switch (Zone.current[#_containingUrl]) {
22+
null => throw StateError(
23+
"containingUrl may only be accessed within a call to canonicalize()."),
24+
#_none => null,
25+
Uri url => url,
26+
var value => throw StateError(
27+
"Unexpected Zone.current[#_containingUrl] value $value.")
28+
};
29+
2030
/// Runs [callback] in a context where [fromImport] returns `true` and
2131
/// [resolveImportPath] uses `@import` semantics rather than `@use` semantics.
2232
T inImportRule<T>(T callback()) =>
2333
runZoned(callback, zoneValues: {#_inImportRule: true});
2434

35+
/// Runs [callback] in a context where [containingUrl] returns [url].
36+
///
37+
/// If [when] is `false`, runs [callback] without setting [containingUrl].
38+
T withContainingUrl<T>(Uri? url, T callback()) =>
39+
// Use #_none as a sentinel value so we can distinguish a containing URL
40+
// that's set to null from one that's unset at all.
41+
runZoned(callback, zoneValues: {#_containingUrl: url ?? #_none});
42+
2543
/// Resolves an imported path using the same logic as the filesystem importer.
2644
///
2745
/// This tries to fill in extensions and partial prefixes and check for a
@@ -82,3 +100,9 @@ String? _exactlyOne(List<String> paths) => switch (paths) {
82100
///
83101
/// Otherwise, returns `null`.
84102
T? _ifInImport<T>(T callback()) => fromImport ? callback() : null;
103+
104+
/// A regular expression matching valid URL schemes.
105+
final _urlSchemeRegExp = RegExp(r"^[a-z0-9+.-]+$");
106+
107+
/// Returns whether [scheme] is a valid URL scheme.
108+
bool isValidUrlScheme(String scheme) => _urlSchemeRegExp.hasMatch(scheme);

‎lib/src/js/compile.dart

+18-4
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ OutputStyle _parseOutputStyle(String? style) => switch (style) {
184184
AsyncImporter _parseAsyncImporter(Object? importer) {
185185
if (importer == null) jsThrow(JsError("Importers may not be null."));
186186

187-
importer as NodeImporter;
187+
importer as JSImporter;
188188
var canonicalize = importer.canonicalize;
189189
var load = importer.load;
190190
if (importer.findFileUrl case var findFileUrl?) {
@@ -200,15 +200,16 @@ AsyncImporter _parseAsyncImporter(Object? importer) {
200200
"An importer must have either canonicalize and load methods, or a "
201201
"findFileUrl method."));
202202
} else {
203-
return JSToDartAsyncImporter(canonicalize, load);
203+
return JSToDartAsyncImporter(canonicalize, load,
204+
_normalizeNonCanonicalSchemes(importer.nonCanonicalScheme));
204205
}
205206
}
206207

207208
/// Converts [importer] into a synchronous [Importer].
208209
Importer _parseImporter(Object? importer) {
209210
if (importer == null) jsThrow(JsError("Importers may not be null."));
210211

211-
importer as NodeImporter;
212+
importer as JSImporter;
212213
var canonicalize = importer.canonicalize;
213214
var load = importer.load;
214215
if (importer.findFileUrl case var findFileUrl?) {
@@ -224,10 +225,23 @@ Importer _parseImporter(Object? importer) {
224225
"An importer must have either canonicalize and load methods, or a "
225226
"findFileUrl method."));
226227
} else {
227-
return JSToDartImporter(canonicalize, load);
228+
return JSToDartImporter(canonicalize, load,
229+
_normalizeNonCanonicalSchemes(importer.nonCanonicalScheme));
228230
}
229231
}
230232

233+
/// Converts a JS-style `nonCanonicalScheme` field into the form expected by
234+
/// Dart classes.
235+
List<String>? _normalizeNonCanonicalSchemes(Object? schemes) =>
236+
switch (schemes) {
237+
String scheme => [scheme],
238+
List<dynamic> schemes => schemes.cast<String>(),
239+
null => null,
240+
_ => jsThrow(
241+
JsError('nonCanonicalScheme must be a string or list of strings, was '
242+
'"$schemes"'))
243+
};
244+
231245
/// Implements the simplification algorithm for custom function return `Value`s.
232246
/// {@link https://github.com/sass/sass/blob/main/spec/types/calculation.md#simplifying-a-calculationvalue}
233247
Value _simplifyValue(Value value) => switch (value) {

‎lib/src/js/compile_options.dart

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@ class CompileOptions {
3030
class CompileStringOptions extends CompileOptions {
3131
external String? get syntax;
3232
external JSUrl? get url;
33-
external NodeImporter? get importer;
33+
external JSImporter? get importer;
3434
}

‎lib/src/js/importer.dart

+8-6
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,25 @@ import 'url.dart';
88

99
@JS()
1010
@anonymous
11-
class NodeImporter {
12-
external Object? Function(String, CanonicalizeOptions)? get canonicalize;
11+
class JSImporter {
12+
external Object? Function(String, CanonicalizeContext)? get canonicalize;
1313
external Object? Function(JSUrl)? get load;
14-
external Object? Function(String, CanonicalizeOptions)? get findFileUrl;
14+
external Object? Function(String, CanonicalizeContext)? get findFileUrl;
15+
external Object? get nonCanonicalScheme;
1516
}
1617

1718
@JS()
1819
@anonymous
19-
class CanonicalizeOptions {
20+
class CanonicalizeContext {
2021
external bool get fromImport;
22+
external JSUrl? get containingUrl;
2123

22-
external factory CanonicalizeOptions({bool fromImport});
24+
external factory CanonicalizeContext({bool fromImport, JSUrl? containingUrl});
2325
}
2426

2527
@JS()
2628
@anonymous
27-
class NodeImporterResult {
29+
class JSImporterResult {
2830
external String? get contents;
2931
external String? get syntax;
3032
external JSUrl? get sourceMapUrl;

‎lib/src/util/span.dart

+4-2
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import 'package:string_scanner/string_scanner.dart';
99
import '../utils.dart';
1010
import 'character.dart';
1111

12-
/// A span that points nowhere, only used for fake AST nodes that will never be
13-
/// presented to the user.
12+
/// A span that points nowhere.
13+
///
14+
/// This is used for fake AST nodes that will never be presented to the user, as
15+
/// well as for embedded compilation failures that have no associated spans.
1416
final bogusSpan = SourceFile.decoded([]).span(0);
1517

1618
extension SpanExtensions on FileSpan {

‎test/dart_api/importer_test.dart

+93
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,99 @@ void main() {
112112
});
113113
});
114114

115+
group("the containing URL", () {
116+
test("is null for a potentially canonical scheme", () {
117+
late TestImporter importer;
118+
compileString('@import "u:orange";',
119+
importers: [
120+
importer = TestImporter(expectAsync1((url) {
121+
expect(importer.publicContainingUrl, isNull);
122+
return url;
123+
}), (_) => ImporterResult('', indented: false))
124+
],
125+
url: 'x:original.scss');
126+
});
127+
128+
test("throws an error outside canonicalize", () {
129+
late TestImporter importer;
130+
compileString('@import "orange";', importers: [
131+
importer =
132+
TestImporter((url) => Uri.parse("u:$url"), expectAsync1((url) {
133+
expect(() => importer.publicContainingUrl, throwsStateError);
134+
return ImporterResult('', indented: false);
135+
}))
136+
]);
137+
});
138+
139+
group("for a non-canonical scheme", () {
140+
test("is set to the original URL", () {
141+
late TestImporter importer;
142+
compileString('@import "u:orange";',
143+
importers: [
144+
importer = TestImporter(expectAsync1((url) {
145+
expect(importer.publicContainingUrl,
146+
equals(Uri.parse('x:original.scss')));
147+
return url.replace(scheme: 'x');
148+
}), (_) => ImporterResult('', indented: false),
149+
nonCanonicalSchemes: {'u'})
150+
],
151+
url: 'x:original.scss');
152+
});
153+
154+
test("is null if the original URL is null", () {
155+
late TestImporter importer;
156+
compileString('@import "u:orange";', importers: [
157+
importer = TestImporter(expectAsync1((url) {
158+
expect(importer.publicContainingUrl, isNull);
159+
return url.replace(scheme: 'x');
160+
}), (_) => ImporterResult('', indented: false),
161+
nonCanonicalSchemes: {'u'})
162+
]);
163+
});
164+
});
165+
166+
group("for a schemeless load", () {
167+
test("is set to the original URL", () {
168+
late TestImporter importer;
169+
compileString('@import "orange";',
170+
importers: [
171+
importer = TestImporter(expectAsync1((url) {
172+
expect(importer.publicContainingUrl,
173+
equals(Uri.parse('x:original.scss')));
174+
return Uri.parse("u:$url");
175+
}), (_) => ImporterResult('', indented: false))
176+
],
177+
url: 'x:original.scss');
178+
});
179+
180+
test("is null if the original URL is null", () {
181+
late TestImporter importer;
182+
compileString('@import "orange";', importers: [
183+
importer = TestImporter(expectAsync1((url) {
184+
expect(importer.publicContainingUrl, isNull);
185+
return Uri.parse("u:$url");
186+
}), (_) => ImporterResult('', indented: false))
187+
]);
188+
});
189+
});
190+
});
191+
192+
test(
193+
"throws an error if the importer returns a canonical URL with a "
194+
"non-canonical scheme", () {
195+
expect(
196+
() => compileString('@import "orange";', importers: [
197+
TestImporter(expectAsync1((url) => Uri.parse("u:$url")),
198+
(_) => ImporterResult('', indented: false),
199+
nonCanonicalSchemes: {'u'})
200+
]), throwsA(predicate((error) {
201+
expect(error, const TypeMatcher<SassException>());
202+
expect(error.toString(),
203+
contains("uses a scheme declared as non-canonical"));
204+
return true;
205+
})));
206+
});
207+
115208
test("uses an importer's source map URL", () {
116209
var result = compileStringToResult('@import "orange";',
117210
importers: [

‎test/dart_api/test_importer.dart

+13-1
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,22 @@ import 'package:sass/sass.dart';
99
class TestImporter extends Importer {
1010
final Uri? Function(Uri url) _canonicalize;
1111
final ImporterResult? Function(Uri url) _load;
12+
final Set<String> _nonCanonicalSchemes;
1213

13-
TestImporter(this._canonicalize, this._load);
14+
/// Public access to the containing URL so that [canonicalize] and [load]
15+
/// implementations can access them.
16+
Uri? get publicContainingUrl => containingUrl;
17+
18+
TestImporter(this._canonicalize, this._load,
19+
{Iterable<String>? nonCanonicalSchemes})
20+
: _nonCanonicalSchemes = nonCanonicalSchemes == null
21+
? const {}
22+
: Set.unmodifiable(nonCanonicalSchemes);
1423

1524
Uri? canonicalize(Uri url) => _canonicalize(url);
1625

1726
ImporterResult? load(Uri url) => _load(url);
27+
28+
bool isNonCanonicalScheme(String scheme) =>
29+
_nonCanonicalSchemes.contains(scheme);
1830
}

‎test/embedded/importer_test.dart

+158
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,46 @@ void main() {
6464
process, errorId, "Missing mandatory field Importer.importer");
6565
await process.shouldExit(76);
6666
});
67+
68+
group("for an importer with nonCanonicalScheme set:", () {
69+
test("path", () async {
70+
process.send(compileString("a {b: c}", importers: [
71+
InboundMessage_CompileRequest_Importer(
72+
path: "somewhere", nonCanonicalScheme: ["u"])
73+
]));
74+
await expectParamsError(
75+
process,
76+
errorId,
77+
"Importer.non_canonical_scheme may only be set along with "
78+
"Importer.importer.importer_id");
79+
await process.shouldExit(76);
80+
});
81+
82+
test("file importer", () async {
83+
process.send(compileString("a {b: c}", importers: [
84+
InboundMessage_CompileRequest_Importer(
85+
fileImporterId: 1, nonCanonicalScheme: ["u"])
86+
]));
87+
await expectParamsError(
88+
process,
89+
errorId,
90+
"Importer.non_canonical_scheme may only be set along with "
91+
"Importer.importer.importer_id");
92+
await process.shouldExit(76);
93+
});
94+
95+
test("unset", () async {
96+
process.send(compileString("a {b: c}",
97+
importer: InboundMessage_CompileRequest_Importer(
98+
nonCanonicalScheme: ["u"])));
99+
await expectParamsError(
100+
process,
101+
errorId,
102+
"Importer.non_canonical_scheme may only be set along with "
103+
"Importer.importer.importer_id");
104+
await process.shouldExit(76);
105+
});
106+
});
67107
});
68108

69109
group("canonicalization", () {
@@ -156,6 +196,86 @@ void main() {
156196
await process.close();
157197
});
158198

199+
group("the containing URL", () {
200+
test("is unset for a potentially canonical scheme", () async {
201+
process.send(compileString('@import "u:orange"', importers: [
202+
InboundMessage_CompileRequest_Importer(importerId: 1)
203+
]));
204+
205+
var request = await getCanonicalizeRequest(process);
206+
expect(request.hasContainingUrl(), isFalse);
207+
await process.close();
208+
});
209+
210+
group("for a non-canonical scheme", () {
211+
test("is set to the original URL", () async {
212+
process.send(compileString('@import "u:orange"',
213+
importers: [
214+
InboundMessage_CompileRequest_Importer(
215+
importerId: 1, nonCanonicalScheme: ["u"])
216+
],
217+
url: "x:original.scss"));
218+
219+
var request = await getCanonicalizeRequest(process);
220+
expect(request.containingUrl, equals("x:original.scss"));
221+
await process.close();
222+
});
223+
224+
test("is unset to the original URL is unset", () async {
225+
process.send(compileString('@import "u:orange"', importers: [
226+
InboundMessage_CompileRequest_Importer(
227+
importerId: 1, nonCanonicalScheme: ["u"])
228+
]));
229+
230+
var request = await getCanonicalizeRequest(process);
231+
expect(request.hasContainingUrl(), isFalse);
232+
await process.close();
233+
});
234+
});
235+
236+
group("for a schemeless load", () {
237+
test("is set to the original URL", () async {
238+
process.send(compileString('@import "orange"',
239+
importers: [
240+
InboundMessage_CompileRequest_Importer(importerId: 1)
241+
],
242+
url: "x:original.scss"));
243+
244+
var request = await getCanonicalizeRequest(process);
245+
expect(request.containingUrl, equals("x:original.scss"));
246+
await process.close();
247+
});
248+
249+
test("is unset to the original URL is unset", () async {
250+
process.send(compileString('@import "u:orange"', importers: [
251+
InboundMessage_CompileRequest_Importer(importerId: 1)
252+
]));
253+
254+
var request = await getCanonicalizeRequest(process);
255+
expect(request.hasContainingUrl(), isFalse);
256+
await process.close();
257+
});
258+
});
259+
});
260+
261+
test(
262+
"fails if the importer returns a canonical URL with a non-canonical "
263+
"scheme", () async {
264+
process.send(compileString("@import 'other'", importers: [
265+
InboundMessage_CompileRequest_Importer(
266+
importerId: 1, nonCanonicalScheme: ["u"])
267+
]));
268+
269+
var request = await getCanonicalizeRequest(process);
270+
process.send(InboundMessage(
271+
canonicalizeResponse: InboundMessage_CanonicalizeResponse(
272+
id: request.id, url: "u:other")));
273+
274+
await _expectImportError(
275+
process, contains('a scheme declared as non-canonical'));
276+
await process.close();
277+
});
278+
159279
test("attempts importers in order", () async {
160280
process.send(compileString("@import 'other'", importers: [
161281
for (var i = 0; i < 10; i++)
@@ -474,6 +594,44 @@ void main() {
474594
await process.close();
475595
});
476596
});
597+
598+
group("fails compilation for an invalid scheme:", () {
599+
test("empty", () async {
600+
process.send(compileString("a {b: c}", importers: [
601+
InboundMessage_CompileRequest_Importer(
602+
importerId: 1, nonCanonicalScheme: [""])
603+
]));
604+
605+
var failure = await getCompileFailure(process);
606+
expect(failure.message,
607+
equals('"" isn\'t a valid URL scheme (for example "file").'));
608+
await process.close();
609+
});
610+
611+
test("uppercase", () async {
612+
process.send(compileString("a {b: c}", importers: [
613+
InboundMessage_CompileRequest_Importer(
614+
importerId: 1, nonCanonicalScheme: ["U"])
615+
]));
616+
617+
var failure = await getCompileFailure(process);
618+
expect(failure.message,
619+
equals('"U" isn\'t a valid URL scheme (for example "file").'));
620+
await process.close();
621+
});
622+
623+
test("colon", () async {
624+
process.send(compileString("a {b: c}", importers: [
625+
InboundMessage_CompileRequest_Importer(
626+
importerId: 1, nonCanonicalScheme: ["u:"])
627+
]));
628+
629+
var failure = await getCompileFailure(process);
630+
expect(failure.message,
631+
equals('"u:" isn\'t a valid URL scheme (for example "file").'));
632+
await process.close();
633+
});
634+
});
477635
}
478636

479637
/// Handles a `CanonicalizeRequest` and returns a response with a generic

‎tool/grind/utils.dart

+4
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ String cloneOrCheckout(String url, String ref, {String? name}) {
5858
}
5959

6060
var path = p.join("build", name);
61+
if (Link(path).existsSync()) {
62+
log("$url is symlinked, leaving as-is");
63+
return path;
64+
}
6165

6266
if (!Directory(p.join(path, '.git')).existsSync()) {
6367
delete(Directory(path));

0 commit comments

Comments
 (0)
Please sign in to comment.