diff --git a/common/changes/@rushstack/heft-sass-plugin/bare-specifier-resolution-options_2026-09-28.json b/common/changes/@rushstack/heft-sass-plugin/bare-specifier-resolution-options_2026-09-28.json new file mode 100644 index 0000000000..c8efb06c87 --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/bare-specifier-resolution-options_2026-09-28.json @@ -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" + } + ] +} diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index a55f5b462c..68ba17ee5e 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -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"`) | @@ -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 @@ -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. diff --git a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts index 32d1ef5a74..1429e775c0 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts @@ -29,6 +29,8 @@ export interface ISassConfigurationJson { nonModuleFileExtensions?: string[]; silenceDeprecations?: string[]; excludeFiles?: string[]; + loadPaths?: string[]; + resolveBareSpecifiersAsPackages?: boolean; doNotTrimOriginalFileExtension?: boolean; preserveIcssExports?: boolean; sourceMap?: boolean; @@ -100,6 +102,8 @@ export default class SassPlugin implements IHeftPlugin { nonModuleFileExtensions, silenceDeprecations, excludeFiles, + loadPaths, + resolveBareSpecifiersAsPackages, doNotTrimOriginalFileExtension, preserveIcssExports, sourceMap @@ -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) => { diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 1ac502077a..7b062c392c 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -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. */ @@ -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; @@ -204,6 +232,8 @@ export class SassProcessor { readonly #resolutions: Map; readonly #isFileModule: (filePath: string) => boolean; + readonly #loadPaths: readonly string[]; + readonly #resolveBareSpecifiersAsPackages: boolean; readonly #options: ISassProcessorOptions; readonly #realpathSync: (path: string) => string; readonly #scssOptions: Options<'async'>; @@ -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; @@ -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 `~` 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('~')`. 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:')) { @@ -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); + } + + /** + * 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; + } } /** @@ -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'); } diff --git a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json index 71d163bff9..f4b34b6085 100644 --- a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json +++ b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json @@ -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." diff --git a/heft-plugins/heft-sass-plugin/src/templates/sass.json b/heft-plugins/heft-sass-plugin/src/templates/sass.json index 2a874148f6..73f4eff658 100644 --- a/heft-plugins/heft-sass-plugin/src/templates/sass.json +++ b/heft-plugins/heft-sass-plugin/src/templates/sass.json @@ -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". diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 4faa802770..dfef765220 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -13,6 +13,45 @@ import { type ICssOutputFolder, type ISassProcessorOptions, SassProcessor } from const projectFolder: string = path.resolve(__dirname, '../..'); const fixturesFolder: string = path.resolve(__dirname, '../../src/test/fixtures'); +/** + * Root of a synthesized project used by the bare specifier tests. It is generated on disk rather than + * checked in because it contains a `node_modules` folder, which is excluded by the repository .gitignore. + */ +const bareSpecifierFolder: string = `${Path.convertToSlashes(projectFolder)}/temp/test/bare-specifiers`; +const bareSpecifierSrcFolder: string = `${bareSpecifierFolder}/src`; + +/** Contents of the synthesized project, keyed by path relative to {@link bareSpecifierFolder}. */ +const BARE_SPECIFIER_FILES: Record = { + // A package that ships Sass sources, like a design system or component library. + 'node_modules/fake-sass-package/package.json': '{ "name": "fake-sass-package", "version": "1.0.0" }', + 'node_modules/fake-sass-package/lib/sass/_colors.scss': '$fake-brand: #00ff00;\n', + + // A package that consumes the one above using a bare specifier. A consuming project cannot rewrite + // this import, so it must resolve without any modification to node_modules. + 'node_modules/shared-styles/package.json': '{ "name": "shared-styles", "version": "1.0.0" }', + 'node_modules/shared-styles/_index.scss': + "@use 'fake-sass-package/lib/sass/colors';\n\n.shared {\n color: colors.$fake-brand;\n}\n", + + // A package with no bare specifiers of its own, so that the legacy `~` tests exercise only the + // tilde rewrite and not the opt-in bare specifier fallback. + 'node_modules/plain-styles/package.json': '{ "name": "plain-styles", "version": "1.0.0" }', + 'node_modules/plain-styles/_index.scss': '.plain {\n color: #123456;\n}\n', + + 'src/bare-import.module.scss': + "@use 'fake-sass-package/lib/sass/colors';\n\n.root {\n color: colors.$fake-brand;\n}\n", + 'src/dependency-bare-import.module.scss': + "@use 'sass:meta';\n\n.root {\n :global {\n @include meta.load-css('pkg:shared-styles');\n }\n}\n", + 'src/tilde-load-css.module.scss': + "@use 'sass:meta';\n\n.root {\n :global {\n @include meta.load-css('~plain-styles');\n }\n}\n", + 'src/missing-bare-import.module.scss': "@use 'definitely-not-a-real-package/colors';\n", + + // A folder that shadows the package name, to verify that relative resolution takes precedence. + // It lives in a subfolder so that it does not shadow the package for the other fixtures. + 'src/nested/fake-sass-package/lib/sass/_colors.scss': '$fake-brand: #0000ff;\n', + 'src/nested/relative-precedence.module.scss': + "@use 'fake-sass-package/lib/sass/colors';\n\n.root {\n color: colors.$fake-brand;\n}\n" +}; + // Fake output folder paths - never actually written to disk because FileSystem.writeFileAsync is mocked. const FAKE_OUTPUT_BASE_FOLDER: string = '/fake/output'; const NORMALIZED_PLATFORM_FAKE_OUTPUT_BASE_FOLDER: string = Path.convertToSlashes( @@ -29,9 +68,11 @@ type ICreateProcessorOptions = Partial< | 'dtsOutputFolders' | 'exportAsDefault' | 'fileExtensions' + | 'loadPaths' | 'nonModuleFileExtensions' | 'postProcessCssAsync' | 'preserveIcssExports' + | 'resolveBareSpecifiersAsPackages' | 'silenceDeprecations' | 'sourceMap' | 'srcFolder' @@ -761,6 +802,125 @@ describe(SassProcessor.name, () => { }); }); + describe('bare specifier resolution', () => { + beforeAll(() => { + // Written with the synchronous API because `FileSystem.writeFileAsync` is mocked per-test. + FileSystem.ensureEmptyFolder(bareSpecifierFolder); + for (const [relativePath, content] of Object.entries(BARE_SPECIFIER_FILES)) { + FileSystem.writeFile(`${bareSpecifierFolder}/${relativePath}`, content, { + ensureFolderExists: true + }); + } + }); + + function createBareSpecifierProcessor(options: ICreateProcessorOptions = {}): { + processor: SassProcessor; + logger: MockScopedLogger; + } { + return createProcessor(terminalProvider, { + srcFolder: bareSpecifierSrcFolder, + ...options + }); + } + + async function compileBareSpecifierFixtureAsync( + processor: SassProcessor, + relativePath: string + ): Promise { + await processor.compileFilesAsync(new Set([`${bareSpecifierSrcFolder}/${relativePath}`])); + } + + it('resolves a bare specifier from node_modules', async () => { + const { processor, logger } = createBareSpecifierProcessor({ + resolveBareSpecifiersAsPackages: true + }); + await compileBareSpecifierFixtureAsync(processor, 'bare-import.module.scss'); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('bare-import.module.scss')).toContain('#00ff00'); + }); + + it('resolves a bare specifier used inside a dependency stylesheet', async () => { + // The failing import lives in node_modules/shared-styles, which the consuming project cannot edit. + const { processor, logger } = createBareSpecifierProcessor({ + resolveBareSpecifiersAsPackages: true + }); + await compileBareSpecifierFixtureAsync(processor, 'dependency-bare-import.module.scss'); + + expect(logger.errors).toHaveLength(0); + const css: string = getCssOutput('dependency-bare-import.module.scss'); + expect(css).toContain('.shared'); + expect(css).toContain('#00ff00'); + }); + + it('resolves a legacy tilde specifier inside meta.load-css()', async () => { + // The `~` rewrite is applied by the resolver, not only by the @use/@import/@forward preprocessor. + // `~` is an explicit package reference, so it is not gated by resolveBareSpecifiersAsPackages; + // this processor deliberately leaves that option at its default. + const { processor, logger } = createBareSpecifierProcessor(); + await compileBareSpecifierFixtureAsync(processor, 'tilde-load-css.module.scss'); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('tilde-load-css.module.scss')).toContain('.plain'); + }); + + it('does not resolve a bare specifier as a package by default', async () => { + // Per the Sass specification a bare specifier is a relative path, so package resolution must + // happen only when the configuration explicitly opts in. This processor does not pass the + // option at all, so it asserts the default value and not merely an explicit `false`. + const { processor, logger } = createBareSpecifierProcessor(); + await compileBareSpecifierFixtureAsync(processor, 'bare-import.module.scss'); + + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0].message).toContain("Can't find stylesheet to import"); + }); + + it('prefers a file relative to the importer over a package of the same name', async () => { + const { processor, logger } = createBareSpecifierProcessor({ + resolveBareSpecifiersAsPackages: true + }); + await compileBareSpecifierFixtureAsync(processor, 'nested/relative-precedence.module.scss'); + + expect(logger.errors).toHaveLength(0); + const css: string = getCssOutput('relative-precedence.module.scss'); + expect(css).toContain('#0000ff'); + expect(css).not.toContain('#00ff00'); + }); + + it('reports a normal Sass error when a bare specifier names no installed package', async () => { + const { processor, logger } = createBareSpecifierProcessor({ + resolveBareSpecifiersAsPackages: true + }); + await compileBareSpecifierFixtureAsync(processor, 'missing-bare-import.module.scss'); + + expect(logger.errors).toHaveLength(1); + const message: string = logger.errors[0].message; + expect(message).toContain("Can't find stylesheet to import"); + // The package resolution failure must not leak out in place of the normal Sass diagnostic. + expect(message).not.toContain('Cannot find package'); + }); + }); + + describe('loadPaths option', () => { + it('resolves a bare specifier from a configured load path', async () => { + const { processor, logger } = createProcessor(terminalProvider, { + loadPaths: [`${fixturesFolder}/load-paths`] + }); + await compileFixtureAsync(processor, 'use-load-path.module.scss'); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('use-load-path.module.scss')).toContain('#ff00ff'); + }); + + it('does not resolve a bare specifier from an unconfigured folder', async () => { + const { processor, logger } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'use-load-path.module.scss'); + + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0].message).toContain("Can't find stylesheet to import"); + }); + }); + describe('sourceMap option', () => { it('emits .css.map and sourceMappingURL comment when sourceMap is true', async () => { const { processor } = createProcessor(terminalProvider, { sourceMap: true }); diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap index d2fab19a07..c31dfb7402 100644 --- a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -174,6 +174,104 @@ module.exports.default = module.exports;", } `; +exports[`SassProcessor bare specifier resolution does not resolve a bare specifier as a package by default: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution does not resolve a bare specifier as a package by default: written-files 1`] = `Map {}`; + +exports[`SassProcessor bare specifier resolution prefers a file relative to the importer over a package of the same name: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution prefers a file relative to the importer over a package of the same name: written-files 1`] = ` +Map { + "/fake/output/dts/nested/relative-precedence.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/nested/relative-precedence.module.css" => ".root { + color: #0000ff; +}", +} +`; + +exports[`SassProcessor bare specifier resolution reports a normal Sass error when a bare specifier names no installed package: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution reports a normal Sass error when a bare specifier names no installed package: written-files 1`] = `Map {}`; + +exports[`SassProcessor bare specifier resolution resolves a bare specifier from node_modules: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution resolves a bare specifier from node_modules: written-files 1`] = ` +Map { + "/fake/output/dts/bare-import.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/bare-import.module.css" => ".root { + color: #00ff00; +}", +} +`; + +exports[`SassProcessor bare specifier resolution resolves a bare specifier used inside a dependency stylesheet: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution resolves a bare specifier used inside a dependency stylesheet: written-files 1`] = ` +Map { + "/fake/output/dts/dependency-bare-import.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/dependency-bare-import.module.css" => ".root .shared { + color: #00ff00; +}", +} +`; + +exports[`SassProcessor bare specifier resolution resolves a legacy tilde specifier inside meta.load-css(): terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution resolves a legacy tilde specifier inside meta.load-css(): written-files 1`] = ` +Map { + "/fake/output/dts/tilde-load-css.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/tilde-load-css.module.css" => ".root .plain { + color: #123456; +}", +} +`; + exports[`SassProcessor classes-and-exports.module.scss generates correct .d.ts with both class names and :export values: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -817,6 +915,35 @@ h1 { } `; +exports[`SassProcessor loadPaths option does not resolve a bare specifier from an unconfigured folder: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor loadPaths option does not resolve a bare specifier from an unconfigured folder: written-files 1`] = `Map {}`; + +exports[`SassProcessor loadPaths option resolves a bare specifier from a configured load path: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor loadPaths option resolves a bare specifier from a configured load path: written-files 1`] = ` +Map { + "/fake/output/dts/use-load-path.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/use-load-path.module.css" => ".root { + color: #ff00ff; +}", +} +`; + exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/load-paths/theme/_colors.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/load-paths/theme/_colors.scss new file mode 100644 index 0000000000..8ddb7955ae --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/load-paths/theme/_colors.scss @@ -0,0 +1 @@ +$brand: #ff00ff; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/use-load-path.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/use-load-path.module.scss new file mode 100644 index 0000000000..e800624b72 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/use-load-path.module.scss @@ -0,0 +1,5 @@ +@use 'theme/colors'; + +.root { + color: colors.$brand; +}