sass/accepted/prev-url.d.ts.md
Natalie Weizenbaum ea2ee6e46d Fix broken links
2024-06-18 16:59:20 -07:00

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:

  1. 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.

  2. Relative URLs are resolved like paths and HTTP URLs. For example, within scheme:a/b/c.scss, the URL ../d should be resolved to scheme:a/d.

  3. Loads relative to the current stylesheet always take precedence over loads from importers, so if scheme:a/b/x.scss exists then @use "x" within scheme:a/b/c.scss will always load it.

Risks

Providing access to the containing URL puts these invariants at risk in two ways:

  1. 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).

  2. 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 calling findFileUrl with string, fromImport, and containingUrl. 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 be true if the importer is being run for an @import and false otherwise.

  • If string is a relative URL, or if it's an absolute URL whose scheme is non-canonical for this importer, let containingUrl be the canonical URL of the current source file. Otherwise, or if the current source file has no canonical URL, let containingUrl be undefined.

  • Let url be the result of calling canonicalize with string, fromImport, and containingUrl. 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 a FileImportRequest with string as its url, fromImport as from_import, and containingUrl as containing_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;