Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"changes": [
{
"packageName": "@rushstack/heft-sass-plugin",
"comment": "Add opt-in `loadPaths` and `resolveBareSpecifiersAsPackages` options, which allow a bare specifier such as `@use '@scope/pkg/theme'` to be resolved from a load path or as a package when it does not resolve relative to the importing file. Both are disabled by default, preserving the specification behavior in which such a specifier is a relative URL.",
"type": "minor"
},
{
"packageName": "@rushstack/heft-sass-plugin",
"comment": "Apply the legacy `~` to `pkg:` rewrite during canonicalization, so that it also works in constructs other than `@use`/`@import`/`@forward`, such as `meta.load-css()`. Previously these threw an `Unexpected tilde in URL` error.",
"type": "patch"
}
]
}
43 changes: 43 additions & 0 deletions heft-plugins/heft-sass-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ All options are set in `config/sass.json`. Every option is optional.
| `fileExtensions` | `[".sass", ".scss", ".css"]` | File extensions to treat as CSS modules |
| `nonModuleFileExtensions` | `[".global.sass", ".global.scss", ".global.css"]` | File extensions to treat as global (non-module) stylesheets |
| `excludeFiles` | `[]` | Paths relative to `srcFolder` to skip entirely |
| `loadPaths` | `[]` | Folders, relative to the project folder, to search when a bare specifier such as `@use "theme/colors"` cannot be resolved relative to the importing file. Analogous to the Sass compiler's `loadPaths` option. |
| `resolveBareSpecifiersAsPackages` | `false` | When `true`, a bare specifier that resolves neither relative to the importing file nor from `loadPaths` is additionally resolved as a package via Node module resolution. See [Bare specifiers](#bare-specifiers). |
| `doNotTrimOriginalFileExtension` | `false` | When `true`, preserves the original extension in the CSS output filename. E.g. `styles.scss` → `styles.scss.css` instead of `styles.css`. Useful when downstream tooling needs to distinguish the source format. |
| `preserveIcssExports` | `false` | When `true`, keeps the `:export { }` block in the emitted CSS. This is needed when a webpack loader (e.g. `css-loader`'s `icssParser`) must extract `:export` values at bundle time. Has no effect on the generated `.d.ts`. |
| `silenceDeprecations` | `[]` | List of Sass deprecation codes to suppress (e.g. `"mixed-decls"`, `"import"`, `"global-builtin"`, `"color-functions"`) |
Expand Down Expand Up @@ -203,6 +205,13 @@ require("./global.global.css");

## Sass import resolution

A load specifier is resolved in this order:

1. Relative to the importing file.
2. Each folder in the `loadPaths` option, in order (bare specifiers only).
3. As a package, via Node module resolution — only when `resolveBareSpecifiersAsPackages` is enabled
(bare specifiers only).

The plugin supports the modern `pkg:` protocol for importing from npm packages:

```scss
Expand All @@ -217,6 +226,40 @@ The legacy `~` prefix is automatically converted to `pkg:` for compatibility wit
@use "pkg:@fluentui/react/dist/sass/variables";
```

This also applies to specifiers that are not part of an `@use`/`@import`/`@forward` rule, such as
`@include meta.load-css("~@fluentui/react/dist/sass/variables")`.

### Bare specifiers

A "bare" specifier is one that does not start with `.`, `/`, or a URL scheme, for example
`@use "@fluentui/react/dist/sass/variables"`.

Per the Sass specification the target of `@use`, `@import` and `@forward` is a **URL**, so a bare
specifier is a *relative path*, not a reference to a package. That is how this plugin treats it by
default, and `pkg:` is the supported way to reference a package:

```scss
// Preferred: unambiguous, and always enabled
@use "pkg:@fluentui/react/dist/sass/variables";
```

Some other Sass toolchains (the Dart Sass CLI's `--load-path`, `sass-loader`, Vite, the Angular CLI)
instead resolve bare specifiers from `node_modules`. Stylesheets authored for those toolchains — most
commonly inside third-party packages, where a consuming project cannot rewrite the import — depend on
that behavior. Two opt-in options support them:

```json
{
"loadPaths": ["src/styles"],
"resolveBareSpecifiersAsPackages": true
}
```

`loadPaths` resolves a bare specifier against a list of folders. `resolveBareSpecifiersAsPackages`
additionally resolves it as a package using Node module resolution, which honors the package's
`exports` field. Both apply only after relative resolution has failed, so enabling them cannot change
the meaning of a specifier that already resolves.

## Incremental builds

The plugin tracks inter-file dependencies (via `@use`, `@forward`, and `@import`) and only recompiles files that changed or whose dependencies changed. This makes `heft build --watch` fast even in large projects.
Expand Down
6 changes: 6 additions & 0 deletions heft-plugins/heft-sass-plugin/src/SassPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ISassConfigurationJson {
nonModuleFileExtensions?: string[];
silenceDeprecations?: string[];
excludeFiles?: string[];
loadPaths?: string[];
resolveBareSpecifiersAsPackages?: boolean;
doNotTrimOriginalFileExtension?: boolean;
preserveIcssExports?: boolean;
sourceMap?: boolean;
Expand Down Expand Up @@ -100,6 +102,8 @@ export default class SassPlugin implements IHeftPlugin {
nonModuleFileExtensions,
silenceDeprecations,
excludeFiles,
loadPaths,
resolveBareSpecifiersAsPackages,
doNotTrimOriginalFileExtension,
preserveIcssExports,
sourceMap
Expand All @@ -117,6 +121,8 @@ export default class SassPlugin implements IHeftPlugin {
exportAsDefault,
srcFolder: resolveFolder(srcFolder),
excludeFiles,
loadPaths: loadPaths?.map(resolveFolder),
resolveBareSpecifiersAsPackages,
fileExtensions,
nonModuleFileExtensions,
cssOutputFolders: cssOutputFolders?.map((folder: string | ICssOutputFolder) => {
Expand Down
90 changes: 88 additions & 2 deletions heft-plugins/heft-sass-plugin/src/SassProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@ export interface ISassProcessorOptions {
*/
excludeFiles?: string[];

/**
* Absolute paths of folders to search when resolving a bare specifier, e.g. `@use 'theme/colors'`.
* These are analogous to the `loadPaths` option of the Sass compiler, and are consulted after
* resolution relative to the importing file fails.
*/
loadPaths?: string[];

/**
* If true, a bare specifier that does not resolve relative to the importing file or from `loadPaths`
* will additionally be resolved as a package, using Node module resolution.
*
* This is off by default because the Sass specification defines the target of `@use`, `@import` and
* `@forward` to be a URL, so `@use '@scope/pkg/theme'` is a relative path rather than a reference to
* a package. Enabling this deviates from the specification, and should only be done when consuming
* stylesheets that rely on it, such as third-party packages authored for toolchains that resolve
* bare specifiers from `node_modules`.
*
* Prefer the unambiguous `pkg:` scheme in stylesheets that you control.
*/
resolveBareSpecifiersAsPackages?: boolean;

/**
* If set, deprecation warnings from dependencies will be suppressed.
*/
Expand Down Expand Up @@ -179,6 +200,13 @@ interface ISerializedFileRecord {
*/
const importTildeRegex: RegExp = /^(\s*@(?:import|use|forward)\s*)('~(?:[^']+)'|"~(?:[^"]+)")/gm;

/**
* Regexp matching the scheme of an absolute URL, e.g. the `pkg:` in `pkg:@fluentui/react/dist/sass/blah`.
* Per RFC 3986 a scheme starts with a letter and may contain letters, digits, `+`, `-` and `.`.
* This also matches a Windows drive letter prefix such as `C:`, which is likewise not a bare specifier.
*/
const urlSchemeRegex: RegExp = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;

// eslint-disable-next-line @rushstack/no-new-null
type SyncResolution = URL | null;
type AsyncResolution = Promise<SyncResolution>;
Expand All @@ -204,6 +232,8 @@ export class SassProcessor {
readonly #resolutions: Map<string, SyncOrAsyncResolution>;

readonly #isFileModule: (filePath: string) => boolean;
readonly #loadPaths: readonly string[];
readonly #resolveBareSpecifiersAsPackages: boolean;
readonly #options: ISassProcessorOptions;
readonly #realpathSync: (path: string) => string;
readonly #scssOptions: Options<'async'>;
Expand Down Expand Up @@ -258,6 +288,8 @@ export class SassProcessor {
this.#configFilePath = undefined;
this.#fileInfo = new Map();
this.#isFileModule = isFileModule;
this.#loadPaths = options.loadPaths ?? [];
this.#resolveBareSpecifiersAsPackages = options.resolveBareSpecifiersAsPackages ?? false;
this.#resolutions = new Map();
this.#options = options;
this.#realpathSync = new RealNodeModulePathResolver().realNodeModulePath;
Expand Down Expand Up @@ -558,7 +590,11 @@ export class SassProcessor {
*/
async #canonicalizeAsync(url: string, context: CanonicalizeContext): AsyncResolution {
if (url.startsWith('~')) {
throw new Error(`Unexpected tilde in URL: ${url} in context: ${context.containingUrl?.href}`);
// Legacy `~<package>` syntax. `preprocessScss` rewrites these to `pkg:` in `@import`, `@use` and
// `@forward` rules, but a tilde can also appear in constructs it does not cover, most notably
// `@include meta.load-css('~<package>')`. Apply the same rewrite here so that all of them behave
// consistently instead of failing with a confusing error.
return await this.#canonicalizePackageAsync(`pkg:${url.slice(1)}`, context);
}

if (url.startsWith('pkg:')) {
Expand All @@ -576,7 +612,46 @@ export class SassProcessor {
}

const resolvedUrl: string = new URL(url, containingUrl.toString()).toString();
return await this.#canonicalizeHeftUrlAsync(resolvedUrl, context);
const relativeResolution: SyncResolution = await this.#canonicalizeHeftUrlAsync(resolvedUrl, context);
if (relativeResolution || !isBareSpecifier(url)) {
return relativeResolution;
}

// Per the Sass specification the target of `@use`/`@import`/`@forward` is a URL, so a bare
// specifier such as `@use '@scope/pkg/theme'` is a relative path and has already been handled
// above. The fallbacks below deviate from that, so each one happens only when the configuration
// explicitly asks for it.
return await this.#canonicalizeBareSpecifierAsync(url, context);
Comment thread
iclanton marked this conversation as resolved.
}

/**
* Resolves a bare specifier, e.g. `theme/colors` or `@fluentui/react/dist/sass/blah`, against the
* opt-in `loadPaths` and `resolveBareSpecifiersAsPackages` options. Returns null when neither option
* is configured.
* @param url - The bare specifier to canonicalize
* @param context - The context in which the canonicalization is being performed
* @returns The canonical URL of the target file, or null if it does not resolve
*/
async #canonicalizeBareSpecifierAsync(url: string, context: CanonicalizeContext): AsyncResolution {
for (const loadPath of this.#loadPaths) {
const candidateUrl: string = pathToHeftUrl(`${loadPath}/${url}`).href;
const result: SyncResolution = await this.#canonicalizeHeftUrlAsync(candidateUrl, context);
if (result) {
return result;
}
}

if (!this.#resolveBareSpecifiersAsPackages) {
return null;
}

try {
return await this.#canonicalizePackageAsync(`pkg:${url}`, context);
} catch {
// The specifier does not name an installed package. Returning null lets Sass report its usual
// "Can't find stylesheet to import" error, which points at the offending line in the stylesheet.
return null;
}
}

/**
Expand Down Expand Up @@ -1060,6 +1135,17 @@ function isSassPartial(filePath: string): boolean {
return path.basename(filePath)[0] === '_';
}

/**
* Determines whether a Sass load specifier is "bare", i.e. it names a package or a file within a load
* path rather than a location relative to the importing file. For example `@fluentui/react/dist/sass/blah`
* and `theme/colors` are bare, while `./colors`, `../theme/colors`, `/theme/colors` and `pkg:blah` are not.
* @param url - The specifier exactly as it was written in the stylesheet
* @returns true if the specifier is bare
*/
function isBareSpecifier(url: string): boolean {
return url.length > 0 && !url.startsWith('.') && !url.startsWith('/') && !urlSchemeRegex.test(url);
}

function getContentsHash(fileName: string, fileContents: string): string {
return crypto.createHmac('sha1', fileName).update(fileContents).digest('base64');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@
}
},

"loadPaths": {
"type": "array",
"description": "Folders, relative to the project folder, to search when resolving a bare specifier such as `@use 'theme/colors'`. Analogous to the Sass compiler's `loadPaths` option. Load paths are consulted only after resolution relative to the importing file fails.",
"items": {
"type": "string",
"pattern": "[^\\\\]"
}
},

"resolveBareSpecifiersAsPackages": {
"type": "boolean",
"description": "If true, a bare specifier that does not resolve relative to the importing file or from `loadPaths` will additionally be resolved as a package, using Node module resolution. Defaults to false, because the Sass specification defines the target of `@use`, `@import` and `@forward` to be a URL, making `@use '@scope/pkg/theme'` a relative path rather than a package reference. Enable this only when consuming stylesheets that rely on bare specifiers being resolved from `node_modules`; prefer the `pkg:` scheme in stylesheets that you control."
},

"ignoreDeprecationsInDependencies": {
"type": "boolean",
"description": "If set, deprecation warnings from dependencies will be suppressed."
Expand Down
22 changes: 22 additions & 0 deletions heft-plugins/heft-sass-plugin/src/templates/sass.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@
*/
// "excludeFiles": [],

/**
* Folders, relative to the project folder, to search when resolving a bare specifier such as
* `@use 'theme/colors'`. Analogous to the Sass compiler's "loadPaths" option. These are consulted
* only after resolution relative to the importing file fails.
*
* Default value: undefined
*/
// "loadPaths": ["src/styles"],

/**
* If true, a bare specifier that resolves neither relative to the importing file nor from
* "loadPaths" will additionally be resolved as a package, using Node module resolution.
*
* Per the Sass specification the target of `@use`, `@import` and `@forward` is a URL, so
* `@use '@scope/pkg/theme'` is a relative path rather than a package reference. Enable this only
* when consuming stylesheets that rely on bare specifiers resolving from "node_modules"; prefer the
* `pkg:` scheme in stylesheets that you control.
*
* Default value: false
*/
// "resolveBareSpecifiersAsPackages": true,

/**
* If true, the original file extension will not be trimmed when generating the output CSS filename.
* For example, "styles.scss" will generate "styles.scss.css" instead of "styles.css".
Expand Down
Loading
Loading