[heft-sass-plugin] Add opt-in bare specifier resolution options - #6094
Ian Clanton-Thuon (iclanton) wants to merge 2 commits into
Conversation
Since the move to the `pkg:` importer, a bare specifier such as `@use '@scope/pkg/theme'` only ever resolved relative to the importing file, so it failed with "Can't find stylesheet to import". There was no configuration option to change this, and the failing import is often inside a third-party package that the consuming project cannot edit. - Bare specifiers now fall back to the new `loadPaths` option and then to `node_modules` when they do not resolve relative to the importing file. Relative resolution still takes precedence, matching Sass semantics. - Package resolution failures return null instead of throwing, so Sass reports its usual error pointing at the offending line. - The legacy `~` rewrite is applied in the resolver rather than only by the `@use`/`@import`/`@forward` preprocessor, so it also works in constructs such as `meta.load-css()`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b66c2e44-e9ac-4611-8c45-85cb60478f7b
47c1903 to
d4e4123
Compare
| "changes": [ | ||
| { | ||
| "packageName": "@rushstack/heft-sass-plugin", | ||
| "comment": "Fix a regression where a bare specifier such as `@use '@scope/pkg/theme'` could not be resolved. Bare specifiers now fall back to the new `loadPaths` option and then to `node_modules` when they do not resolve relative to the importing file. Also apply the legacy `~` rewrite in constructs other than `@use`/`@import`/`@forward`, such as `meta.load-css()`.", |
There was a problem hiding this comment.
Per CSS and SCSS spec, @scope/pkg/theme is a relative path. Having @use perform node_module resolution is a deviation from the spec, so therefore introducing it cannot be considered "fixing a regression" when the behavior was 100% by design.
There was a problem hiding this comment.
Agreed, and the change file has been rewritten. It no longer claims a regression: the options are now described as new opt-in behavior, with the package-resolution one marked minor rather than patch, and its description states that the default preserves the spec behavior in which such a specifier is a relative URL.
The ~ rewrite is split out into a separate patch entry, since that one is genuinely a defect fix rather than new behavior.
| // `node_modules`, matching the behavior of the Sass `loadPaths` option and `NodePackageImporter`. | ||
| // This form is what non-Heft Sass toolchains emit, so stylesheets inside third-party packages | ||
| // frequently use it and cannot be rewritten by the consuming project. | ||
| return await this.#canonicalizeBareSpecifierAsync(url, context); |
There was a problem hiding this comment.
Please don't interpret a relative specifier as an external module specifier unless the configuration explicitly asks to, since, again, this is a deviation from the import spec (the target of @import is a URL).
There was a problem hiding this comment.
You are right, and I have reworked the PR accordingly.
Package resolution of a bare specifier is now behind a new resolveBareSpecifiersAsPackages option that defaults to false, and loadPaths applies only when it is configured. With neither set, a bare specifier is resolved exactly as before: relative to the importing file, per the URL semantics in the spec. Both fallbacks also run only after relative resolution has failed, so enabling them cannot change the meaning of a specifier that already resolves.
The guard test for this omits the option entirely rather than passing an explicit false, so it asserts the default itself. I confirmed it bites by flipping the default to true in a scratch build: that fails precisely this test and nothing else.
One thing I deliberately did not gate is the legacy ~ rewrite, since ~ is an explicit package reference rather than a relative URL, and the plugin already resolves it as a package in @use/@import/@forward. The change only makes it behave consistently in constructs the preprocessor regex does not cover, such as meta.load-css(), which previously threw Unexpected tilde in URL. Happy to gate it too if you would rather it were opt-in.
Addresses review feedback: per the Sass specification the target of `@use`/`@import`/`@forward` is a URL, so `@use '@scope/pkg/theme'` is a relative path and resolving it from node_modules is a deviation. That deviation is now opt-in rather than automatic. - Add `resolveBareSpecifiersAsPackages`, defaulting to false. Package resolution of a bare specifier happens only when it is enabled. - `loadPaths` is likewise consulted only when configured. - The legacy `~` rewrite is unchanged by this commit; `~` is an explicit package reference, so it is not gated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b66c2e44-e9ac-4611-8c45-85cb60478f7b
Summary
Adds two opt-in options for resolving bare specifiers in Sass stylesheets, e.g.
@use '@scope/pkg/theme', and fixes the legacy~rewrite in constructs other than@use/@import/@forward.Motivation
Some other Sass toolchains — the Dart Sass CLI's
--load-path,sass-loader, Vite, the Angular CLI — resolve bare specifiers fromnode_modules. Stylesheets authored for those toolchains rely on it, and the import is frequently inside a third-party package, where a consuming project cannot rewrite it. Today there is no way to consume such a package:loadPathswas never exposed, andsass.jsonisadditionalProperties: false, so there is no escape hatch.These options provide one without changing the default behavior for anyone else. Reported downstream against SPFx 1.23 in microsoft/sp-dev-docs#11030.
Changes
loadPaths— folders, resolved against the project folder, searched when a bare specifier does not resolve relative to the importing file. Analogous to the Sass compiler option of the same name.resolveBareSpecifiersAsPackages— whentrue, a bare specifier that resolves neither relatively nor fromloadPathsis additionally resolved as a package via Node module resolution. Defaults tofalse.nullrather than propagating, so Sass reports its usualCan't find stylesheet to importpointing at the offending line, instead of an internalCannot find package "...".~handled in the resolver. Previously the~→pkg:rewrite was a regex over@import/@use/@forwardonly, and any surviving tilde threwUnexpected tilde in URL. The rewrite now also happens during canonicalization, so~works in@include meta.load-css('~@scope/pkg').~is an explicit package reference, so it is not gated behindresolveBareSpecifiersAsPackages.Testing
heft test— 65 passing, 8 new.New coverage: resolution from
node_moduleswhen enabled; resolution inside a dependency stylesheet (the reported scenario);~insidemeta.load-css(); and resolution vialoadPaths.Four are guard tests pinning behavior that must not change:
false;I verified the tests fail for the right reasons by mutation: flipping the
resolveBareSpecifiersAsPackagesdefault totruefails exactly the default-behavior guard and nothing else, and reverting the resolver change fails exactly the four fix-targeting tests — reproducingUnexpected tilde in URL: ~plain-styles— while the guards stay green.Because
node_modules/is gitignored, the package fixture tree is generated at test time under the project'stemp/test/, so snapshots stay checkout-independent.Docs
README options table plus a rewritten "Sass import resolution" section documenting the resolution order, why bare specifiers are not resolved as packages by default, and when to prefer
pkg:.templates/sass.jsongains commented entries for both options.