See #3247
11 KiB
Containing URL: Draft 2.0
(Issue)
import {PromiseOr} from '../spec/js-api/util/promise_or';
Table of Contents
Background
This section is non-normative.
Among many other changes, the [new importer API] dropped an importer's ability to access the URL of the stylesheet that contained the load, known in the legacy API as the "previous URL". This was an intentional design choice which enforced the invariant that the same canonical URL always refers to the same file.
However, this restriction makes it difficult for importers to work as expected
in certain contexts. For example, in the Node.js ecosystem JS loads depend on
the structure of the node_modules
directory closest to the containing file.
The new import API can't match this behavior.
This is particularly problematic for the widely-used Webpack importer, which expands on the concept of directory-specific load contexts to allow users to do fine-grained customization of how different files will load their dependencies. In order to ease migration to the new API for this plugin and its users, and to better match external ecosystems' load semantics, a solution is needed.
Summary
This section is non-normative.
This proposal adds an additional option to the Importer.canonicalize()
API
that provides the canonical URL of the containing file (the "containing URL").
However, in order to preserve the desired invariants, this option is only
provided when either:
-
Importer.canonicalize()
is being passed a relative URL (which means the URL has already been tried as a load relative to the current canonical URL), or -
Importer.canonicalize()
is passed an absolute URL whose scheme the importer has declared as non-canonical.
A "non-canonical" scheme is a new concept introduced by this proposal.
Importers will optionally be able to provide a nonCanonicalScheme
field which
will declare one or more URL schemes that they'll never return from
canonicalize()
. (If they do, Sass will throw an error.)
Design Decisions
Invariants
The specific restrictions for this API were put in place to preserve the following invariants:
-
There must be a one-to-one mapping between canonical URLs and stylesheets. This means that even when a user loads a stylesheet using a relative URL, that stylesheet must have an absolute canonical URL associated with it and loading that canonical URL must return the same stylesheet. This means that any stylesheet can always be unambiguously loaded using its canonical URL.
-
Relative URLs are resolved like paths and HTTP URLs. For example, within
scheme:a/b/c.scss
, the URL../d
should be resolved toscheme:a/d
. -
Loads relative to the current stylesheet always take precedence over loads from importers, so if
scheme:a/b/x.scss
exists then@use "x"
withinscheme:a/b/c.scss
will always load it.
Risks
Providing access to the containing URL puts these invariants at risk in two ways:
-
Access to the containing URL in addition to a canonical URL makes it possible for importer authors to handle the same canonical URL differently depending in different contexts, violating invariant (1).
-
It's likely that importer authors familiar with the legacy API will incorrectly assume that any containing URL that exists is the best way to handle relative loads, since the only way to do so in the legacy API was to manually resolve them relative to the
prev
parameter. Doing so will almost certainly lead to violations of invariant (3) and possibly (2).
Alternatives Considered
To mitigate these risks, we need to have some restriction on when the containing URL is available to importers. We considered the following alternative restrictions before settling on the current one:
Unavailable for Pre-Resolved Loads
Don't provide the containing URL when the canonicalize()
function is called
for pre-resolved relative loads. When the user loads a relative URL, the Sass
compiler first resolves that URL against the current canonical URL and passes
the resulting absolute URL to the current importer's canonicalize()
function.
This invocation would not have access to the containing URL; all other
invocations would, including when Sass passes the relative URL as-is to
canonicalize()
.
This mitigates risk (2) by ensuring that all relative URL resolution is handled by the compiler by default. The importer will be invoked with an absolute URL and no containing URL first for each relative load, which will break for any importers that naïvely try to use the containing URL in all cases.
This has several drawbacks. First, a badly-behaved importer could work around
this by returning null
for all relative loads and then manually resolving
relative URLs as part of its load path resolution, thus continuing to violate
invariant (3). Second, this provides no protection against risk (1) since the
stylesheet author may still directly load a canonical URL.
Unavailable for Absolute Loads
Don't provide the containing URL when the canonicalize()
function is being
called for any absolute URL. Since relative loads always pass absolute URLs to
their importers first, this is a superset of "Unavailable for Pre-Resolved
Loads". In addition, it protects against risk (1) by ensuring that all absolute
URLs (which are a superset of canonical URLs) are canonicalized without regard
to context.
However, this limits the functionality of importers that use a custom URL scheme
for non-canonical URLs. For example, if we choose to support package imports
by claiming the pkg:
scheme as a "built-in package importer", implementations
of this scheme wouldn't be able to do context-sensitive resolution. This would
make the scheme useless for supporting Node-style resolution, a core use-case.
Given that we want to encourage users to use URL schemes rather than relative
URLs, this is a blocking limitation.
Thus we arrive at the actual behavior, which makes the containing URL
unavailable for absolute loads unless they have a URL scheme declared
explicitly non-canonical. This supports the pkg:
use-case while still
protecting against risk (1), since the containing URL is never available for
canonical resolutions.
Types
declare module '../spec/js-api/importer' {
FileImporter
Replace the invocation of findFileUrl
with:
-
Let
containingUrl
be the canonical URL of the current source file if it has one, or undefined otherwise. -
Let
url
be the result of callingfindFileUrl
withstring
,fromImport
, andcontainingUrl
. If it returns a promise, wait for it to complete and use its value instead, or rethrow its error if it rejects.
interface FileImporter<sync extends 'sync' | 'async' = 'sync' | 'async'> {
findFileUrl
findFileUrl(
url: string,
options: {fromImport: boolean; containingUrl?: URL}
): PromiseOr<URL | null, sync>;
} // FileImporter
Importer
Replace the first two bullet points for invoking an importer with a string with:
-
Let
fromImport
betrue
if the importer is being run for an@import
andfalse
otherwise. -
If
string
is a relative URL, or if it's an absolute URL whose scheme is non-canonical for this importer, letcontainingUrl
be the canonical URL of the current source file. Otherwise, or if the current source file has no canonical URL, letcontainingUrl
be undefined. -
Let
url
be the result of callingcanonicalize
withstring
,fromImport
, andcontainingUrl
. If it returns a promise, wait for it to complete and use its value instead, or rethrow its error if it rejects. -
If the scheme of
url
is non-canonical for this importer, throw an error.
interface Importer<sync extends 'sync' | 'async' = 'sync' | 'async'> {
nonCanonicalScheme
The set of URL schemes that are considered non-canonical for this importer. If this is a single string, treat it as a list containing that string.
Before beginning compilation, throw an error if any element of this is empty or
contains a character other than a lowercase ASCII letter, an ASCII numeral,
U+002B (+
), U+002D (-
), or U+002E (.
).
Uppercase letters are normalized to lowercase in the
URL
constructor, so for simplicity and efficiency we only allow lowercase here.
nonCanonicalScheme?: string | string[];
canonicalize
canonicalize(
url: string,
options: {fromImport: boolean; containingUrl?: URL}
): PromiseOr<URL | null, sync>;
}
} // Importer
Embedded Protocol
Importer
non_canonical_scheme
The set of URL schemes that are considered non-canonical for this importer.
This must be empty unless importer.importer_id
is set.
If any element of this contains a character other than a lowercase ASCII letter,
an ASCII numeral, U+002B (+
), U+002D (-
), or U+002E (.
), the compiler must
treat the compilation as failed.
repeated string non_canonical_scheme = 4;
CanonicalizeRequest
containing_url
The canonical URL of the current source file that contained the load to be canonicalized.
The compiler must set this if and only if url
is relative or its scheme is
non-canonical for the importer being invoked, unless the
current source file has no canonical URL.
optional string containing_url = 6;
CanonicalizeResponse
url
If this URL's scheme is non-canonical for this importer, the compiler must treat that as an error thrown by the importer.
FileImportRequest
Replace the sending of FileImportRequest
with:
-
Let
containingUrl
be the canonical URL of the current source file if it has one, or undefined otherwise. -
Let
response
be the result of sending aFileImportRequest
withstring
as itsurl
,fromImport
asfrom_import
, andcontainingUrl
ascontaining_url
.
containing_url
The canonical URL of the current source file that contained the load to be canonicalized. The compiler must set this unless the current source file has no canonical URL.
optional string containing_url = 6;