From e1d1e2a1ca5772f831fd1a58b2e031afd68de435 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Tue, 20 Oct 2026 19:32:17 -0400 Subject: [PATCH 1/4] [heft-sass-plugin] Honor package exports for Sass imports Resolve Sass-aware package entry points while preserving Heft loading, dependency tracking, source maps, and legacy imports. Add regression coverage and document the supported resolution behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...sass-package-exports_2026-10-20-18-44.json | 10 + .../rush/browser-approved-packages.json | 4 + .../config/subspaces/default/pnpm-lock.yaml | 8 + .../config/subspaces/default/repo-state.json | 2 +- heft-plugins/heft-sass-plugin/README.md | 41 +- heft-plugins/heft-sass-plugin/package.json | 3 +- .../heft-sass-plugin/src/SassProcessor.ts | 141 +++- .../src/test/SassProcessor.test.ts | 258 +++++++ .../__snapshots__/SassProcessor.test.ts.snap | 635 ++++++++++++++++++ 9 files changed, 1088 insertions(+), 14 deletions(-) create mode 100644 common/changes/@rushstack/heft-sass-plugin/sass-package-exports_2026-10-20-18-44.json diff --git a/common/changes/@rushstack/heft-sass-plugin/sass-package-exports_2026-10-20-18-44.json b/common/changes/@rushstack/heft-sass-plugin/sass-package-exports_2026-10-20-18-44.json new file mode 100644 index 0000000000..8211198939 --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/sass-package-exports_2026-10-20-18-44.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-sass-plugin", + "comment": "Honor Sass package exports and sass/style entry points when resolving package imports.", + "type": "patch" + } + ], + "packageName": "@rushstack/heft-sass-plugin" +} diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 9420e1c366..2f7d66d04a 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -118,6 +118,10 @@ "name": "redux", "allowedCategories": [ "libraries", "vscode-extensions" ] }, + { + "name": "resolve-pkg-maps", + "allowedCategories": [ "libraries" ] + }, { "name": "rxjs", "allowedCategories": [ "libraries" ] diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 8fcb6ae40f..4b5f4de493 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3514,6 +3514,9 @@ importers: postcss-modules: specifier: ~6.0.0 version: 6.0.1(postcss@8.5.12) + resolve-pkg-maps: + specifier: ~1.0.0 + version: 1.0.0 sass-embedded: specifier: ~1.85.1 version: 1.85.1 @@ -17374,6 +17377,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha1-YWs9wsVwVrVYjDHN9LPWTbEzcg8=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz} + resolve-url@0.2.1: resolution: {integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==} deprecated: https://github.com/lydell/resolve-url#deprecated @@ -36299,6 +36305,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve-url@0.2.1: {} resolve.exports@2.0.3: {} diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index 5694a332b3..6259b45325 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "5ed0eabdfff6ff042769698020ac33348d56a95b", + "pnpmShrinkwrapHash": "e2984894e141a957f8a73dbd5afe270b40016569", "preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c" } diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index 68ba17ee5e..c06adf4462 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -229,6 +229,43 @@ The legacy `~` prefix is automatically converted to `pkg:` for compatibility wit 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")`. +### Package entry points and exports + +Package imports honor the package's `exports` field, using the `sass`, `style`, and `default` +conditions in the order they appear in the manifest. JavaScript-specific conditions such as `import` +and `require` are not selected. For example: + +```json +{ + "exports": { + ".": { + "sass": "./styles/_index.scss", + "default": "./lib/index.js" + }, + "./tokens": { + "sass": "./styles/_tokens.scss" + }, + "./themes/*.scss": "./styles/themes/*.scss" + } +} +``` + +```scss +@use "pkg:my-library"; +@use "pkg:my-library/tokens"; +@use "pkg:my-library/themes/dark"; +``` + +Exported subpaths support Sass's extension, partial, and index-file lookup. Export **targets** must +specify complete `.scss`, `.sass`, or `.css` filenames; they are not expanded to partials or given +implicit extensions. Multiple matching exports are reported as an ambiguity. + +When no export matches, a package-root import tries the top-level `sass` and `style` fields, followed +by an index stylesheet. A deep import falls back to the corresponding physical path in the package. +These fallbacks follow Sass's package-import conventions and preserve compatibility with packages +that do not declare Sass exports. Legacy `~` imports and opt-in bare package imports use the same +package resolution. + ### Bare specifiers A "bare" specifier is one that does not start with `.`, `/`, or a URL scheme, for example @@ -256,8 +293,8 @@ that behavior. Two opt-in options support them: ``` `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 +additionally locates the package using Node module resolution and resolves its Sass entry points as +described above. Both apply only after relative resolution has failed, so enabling them cannot change the meaning of a specifier that already resolves. ## Incremental builds diff --git a/heft-plugins/heft-sass-plugin/package.json b/heft-plugins/heft-sass-plugin/package.json index f3b2bf3148..554b5de522 100644 --- a/heft-plugins/heft-sass-plugin/package.json +++ b/heft-plugins/heft-sass-plugin/package.json @@ -55,7 +55,8 @@ "postcss": "~8.5.10", "postcss-modules": "~6.0.0", "sass-embedded": "~1.85.1", - "tapable": "1.1.3" + "tapable": "1.1.3", + "resolve-pkg-maps": "~1.0.0" }, "devDependencies": { "@microsoft/api-extractor": "workspace:*", diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 7b062c392c..8a58581a1c 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -20,6 +20,7 @@ import { } from 'sass-embedded'; import * as postcss from 'postcss'; import cssModules from 'postcss-modules'; +import { resolveExports, type PathConditions } from 'resolve-pkg-maps'; import type { IScopedLogger } from '@rushstack/heft'; import { @@ -28,6 +29,7 @@ import { FileSystem, type IFileSystemWriteFileOptions, Import, + JsonFile, type JsonObject, Path, RealNodeModulePathResolver, @@ -35,6 +37,7 @@ import { } from '@rushstack/node-core-library'; const SIMPLE_IDENTIFIER_REGEX: RegExp = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; +const SASS_FILE_EXTENSIONS: readonly string[] = ['.scss', '.sass', '.css']; /** * @public @@ -263,9 +266,10 @@ export class SassProcessor { const loadAsync: (url: URL) => Promise = async (url) => { const absolutePath: string = heftUrlToPath(url.href); - const record: IFileRecord = this.#getOrCreateRecord(absolutePath); - if (record.content === undefined) { + let record: IFileRecord | undefined = this.#fileInfo.get(absolutePath); + if (record?.content === undefined) { const { content, version } = await this.#readFileContentAsync(absolutePath); + record ??= this.#getOrCreateRecord(absolutePath); record.version = version; record.content = content; } @@ -502,7 +506,7 @@ export class SassProcessor { /** * Resolves a `pkg:` URL to a physical file path. - * @param url - The URL to canonicalize. The URL must be a deep import to a SCSS file in a separate package. + * @param url - The URL to canonicalize. The URL must reference a package or a stylesheet in a package. * @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 */ @@ -528,7 +532,7 @@ export class SassProcessor { /** * Resolves a `pkg:` URL to a physical file path, without caching. - * @param url - The URL to canonicalize. The URL must be a deep import to a SCSS file in a separate package. + * @param url - The URL to canonicalize. The URL must reference a package or a stylesheet in a package. * @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 */ @@ -556,12 +560,104 @@ export class SassProcessor { baseFolderPath, getRealPath: this.#realpathSync }); - const modulePath: string = nodeModulesQuery.slice(linkEnd); - const resolvedPath: string = `${resolvedPackagePath}${modulePath}`; + const modulePath: string = nodeModulesQuery.slice(linkEnd + 1); + const packageJson: { + exports?: PathConditions | null; + sass?: unknown; + style?: unknown; + } = await JsonFile.loadAsync(path.join(resolvedPackagePath, 'package.json')); + + if (packageJson.exports !== undefined && packageJson.exports !== null) { + const exportedPath: string | undefined = await this.#resolvePackageExportsAsync( + resolvedPackagePath, + modulePath, + packageJson.exports + ); + if (exportedPath) { + // Exports specify complete filenames, without implicit partials or extensions. + return pathToHeftUrl(exportedPath); + } + } + + if (!modulePath) { + for (const field of [packageJson.sass, packageJson.style]) { + if (typeof field === 'string') { + const result: SyncResolution = await this.#canonicalizeHeftUrlAsync( + pathToHeftUrl(path.resolve(resolvedPackagePath, field)).href, + context + ); + if (result) { + return result; + } + } + } + } + + const resolvedPath: string = path.join(resolvedPackagePath, modulePath); const heftUrl: string = pathToHeftUrl(resolvedPath).href; return await this.#canonicalizeHeftUrlAsync(heftUrl, context); } + async #resolvePackageExportsAsync( + packageFolder: string, + subpath: string, + packageExports: PathConditions + ): Promise { + const subpaths: string[] = [subpath]; + if (!path.posix.extname(subpath)) { + subpaths.push(subpath ? `${subpath}/index` : 'index'); + } + + for (const candidateSubpath of subpaths) { + const matches: Set = new Set(); + for (const candidate of getPackageExportCandidates(candidateSubpath)) { + let targets: string[]; + try { + targets = resolveExports(packageExports, candidate, ['sass', 'style']); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') { + continue; + } + throw error; + } + + const target: string | undefined = targets[0]; + if (!target) { + continue; + } + if (!target.startsWith('./')) { + throw new Error(`Export "${target}" must be relative to the package root at "${packageFolder}".`); + } + const resolvedPath: string = fileURLToPath( + new URL(target, pathToFileURL(`${packageFolder}${path.sep}`)) + ); + const isPattern: boolean = + !!candidate && !Object.prototype.hasOwnProperty.call(packageExports, `./${candidate}`); + if (isPattern && !(await FileSystem.existsAsync(resolvedPath))) { + continue; + } + matches.add(resolvedPath); + } + + if (matches.size > 1) { + throw new Error( + `Ambiguous Sass exports for "${subpath || '.'}" in "${packageFolder}": ${[...matches].join(', ')}` + ); + } + for (const match of matches) { + if (!SASS_FILE_EXTENSIONS.includes(path.extname(match))) { + throw new Error( + `The export for "${subpath || '.'}" in "${packageFolder}" resolved to "${match}", ` + + 'which is not a .scss, .sass, or .css file.' + ); + } + return match; + } + } + + return undefined; + } + /** * Resolves a `heft:` URL to a physical file path. * @param url - The URL to canonicalize. The URL must be a deep import to a candidate SCSS file. @@ -647,10 +743,16 @@ export class SassProcessor { 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; + } catch (error) { + // Import.resolvePackageAsync wraps MODULE_NOT_FOUND without preserving its error code. + if ( + error instanceof Error && + error.message.startsWith('Cannot find package ') && + error.message.includes('Cannot find module ') + ) { + return null; + } + throw error; } } @@ -1146,6 +1248,25 @@ function isBareSpecifier(url: string): boolean { return url.length > 0 && !url.startsWith('.') && !url.startsWith('/') && !urlSchemeRegex.test(url); } +function getPackageExportCandidates(subpath: string): string[] { + if (!subpath) { + return ['']; + } + + const candidates: string[] = [subpath]; + if (!SASS_FILE_EXTENSIONS.includes(path.posix.extname(subpath))) { + candidates.push(...SASS_FILE_EXTENSIONS.map((extension) => `${subpath}${extension}`)); + } + if (!path.posix.basename(subpath).startsWith('_')) { + candidates.push( + ...candidates.map((candidate) => + path.posix.join(path.posix.dirname(candidate), `_${path.posix.basename(candidate)}`) + ) + ); + } + return candidates; +} + 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/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index dfef765220..148d262ddb 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -901,6 +901,264 @@ describe(SassProcessor.name, () => { }); }); + describe('package exports resolution', () => { + const testFolder: string = `${Path.convertToSlashes(projectFolder)}/temp/test/package-exports`; + const srcFolder: string = `${testFolder}/src`; + const packageFolder: string = `${testFolder}/node_modules/@test/exported-styles`; + const sourcePath: string = `${srcFolder}/exports.module.scss`; + + function writePackage(fields: Record): void { + FileSystem.writeFile( + `${packageFolder}/package.json`, + JSON.stringify({ name: '@test/exported-styles', version: '1.0.0', ...fields }), + { ensureFolderExists: true } + ); + } + + async function compilePackageAsync( + specifier: string = 'pkg:@test/exported-styles', + options: ICreateProcessorOptions = {} + ): Promise<{ processor: SassProcessor; logger: MockScopedLogger }> { + FileSystem.writeFile(sourcePath, `@use '${specifier}' as tokens;\n.root { color: tokens.$brand; }\n`, { + ensureFolderExists: true + }); + const result: { processor: SassProcessor; logger: MockScopedLogger } = createProcessor( + terminalProvider, + { srcFolder, ...options } + ); + await result.processor.compileFilesAsync(new Set([sourcePath])); + return result; + } + + beforeEach(() => { + FileSystem.ensureEmptyFolder(testFolder); + const files: Record = { + '_index.scss': '$brand: #00ff00;\n', + 'styles/_tokens.scss': "@use './palette';\n$brand: palette.$brand;\n", + 'styles/_palette.scss': '$brand: #123456;\n', + 'styles/_spaced tokens.scss': '$brand: #123456;\n', + 'styles/other.scss': '$brand: #abcdef;\n', + 'styles/plain.css': '.plain { color: #123456; }\n', + 'styles/indented.sass': '$brand: #123456\n', + 'index.js': 'module.exports = {};\n' + }; + for (const [relativePath, content] of Object.entries(files)) { + FileSystem.writeFile(`${packageFolder}/${relativePath}`, content, { ensureFolderExists: true }); + } + }); + + afterAll(() => { + FileSystem.deleteFolder(testFolder); + }); + + it.each([ + './styles/_tokens.scss', + { sass: './styles/_tokens.scss', default: './index.js' }, + { '.': { sass: './styles/_tokens.scss', default: './index.js' } }, + { '.': ['./styles/_tokens.scss', './styles/other.scss'] }, + { '.': { style: './styles/_tokens.scss' } }, + { '.': { default: './styles/_tokens.scss' } }, + { '.': { import: './index.js', require: './index.js', sass: './styles/_tokens.scss' } } + ])('resolves the package root with exports %j', async (exports) => { + writePackage({ exports }); + const { logger } = await compilePackageAsync(); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + }); + + it('matches conditions in manifest order rather than imposing sass-first priority', async () => { + writePackage({ + exports: { '.': { style: './styles/other.scss', sass: './styles/_tokens.scss' } } + }); + const { logger } = await compilePackageAsync(); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#abcdef'); + }); + + it.each([ + ['./tokens', 'tokens'], + ['./tokens.scss', 'tokens'], + ['./_tokens.scss', 'tokens'], + ['./theme/index.scss', 'theme'], + ['./theme/_index.scss', 'theme'], + ['./theme/*.scss', 'theme/tokens'], + ['./theme/*.scss', 'theme/_tokens.scss'] + ])('resolves export %s from subpath %s', async (key, subpath) => { + writePackage({ + exports: { + [key]: key.includes('*') ? './styles/_*.scss' : { sass: './styles/_tokens.scss' } + } + }); + // Use an existing filename for both wildcard spellings. + if (subpath === 'theme/_tokens.scss') { + writePackage({ exports: { [key]: './styles/*.scss' } }); + } + const { logger } = await compilePackageAsync(`pkg:@test/exported-styles/${subpath}`); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + }); + + it('prefers an exact export over a wildcard export', async () => { + writePackage({ + exports: { + './theme/*': './styles/*', + './theme/_tokens.scss': './styles/other.scss' + } + }); + const { logger } = await compilePackageAsync('pkg:@test/exported-styles/theme/_tokens.scss'); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#abcdef'); + }); + + it.each(['pkg:', '~', ''])('supports the %j package-import prefix', async (prefix) => { + writePackage({ exports: { './tokens': { sass: './styles/_tokens.scss' } } }); + const { logger } = await compilePackageAsync(`${prefix}@test/exported-styles/tokens`, { + resolveBareSpecifiersAsPackages: prefix === '' + }); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + }); + + it.each(['sass', 'style'])('falls back to the top-level %s field', async (field) => { + writePackage({ + exports: { '.': { import: './index.js' } }, + [field]: './styles/_tokens.scss' + }); + const { logger } = await compilePackageAsync(); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + }); + + it('prefers a top-level sass entry point over style', async () => { + writePackage({ sass: './styles/_tokens.scss', style: './styles/other.scss' }); + const { logger } = await compilePackageAsync(); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + }); + + it('preserves physical deep imports when no Sass export matches', async () => { + writePackage({ exports: { '.': { import: './index.js' } } }); + const { logger } = await compilePackageAsync('pkg:@test/exported-styles/styles/tokens'); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + }); + + it('falls back to the package index when no entry point matches', async () => { + writePackage({ exports: { '.': { import: './index.js' } }, main: './index.js' }); + const { logger } = await compilePackageAsync(); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#00ff00'); + }); + + it('loads an exported indented Sass stylesheet', async () => { + writePackage({ exports: { '.': { sass: './styles/indented.sass' } } }); + const { logger } = await compilePackageAsync(); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + }); + + it('decodes URL-encoded export targets', async () => { + writePackage({ exports: { '.': { sass: './styles/_spaced%20tokens.scss' } } }); + const { logger } = await compilePackageAsync(); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + }); + + it('preserves source maps for rules loaded through package exports', async () => { + writePackage({ exports: { '.': { sass: './styles/_tokens.scss' } } }); + const paletteContent: string = '$brand: #123456;\n.palette { color: $brand; }\n'; + FileSystem.writeFile(`${packageFolder}/styles/_palette.scss`, paletteContent); + const { logger } = await compilePackageAsync('pkg:@test/exported-styles', { sourceMap: true }); + const sourceMap: { sources: string[]; sourcesContent: string[] } = JSON.parse( + getWrittenFile('exports.module.css.map') + ); + + expect(logger.errors).toHaveLength(0); + expect(sourceMap.sources.some((source) => source.endsWith('/_palette.scss'))).toBe(true); + expect(sourceMap.sourcesContent).toContain(paletteContent); + }); + + it('loads exported plain CSS through meta.load-css()', async () => { + writePackage({ exports: { './plain': { style: './styles/plain.css' } } }); + FileSystem.writeFile( + sourcePath, + '@use "sass:meta";\n@include meta.load-css("pkg:@test/exported-styles/plain");\n', + { ensureFolderExists: true } + ); + const { processor, logger } = createProcessor(terminalProvider, { srcFolder }); + await processor.compileFilesAsync(new Set([sourcePath])); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + }); + + it('retains dependency tracking for exported files and their relative imports', async () => { + writePackage({ exports: { '.': { sass: './styles/_tokens.scss' } } }); + const { processor, logger } = await compilePackageAsync(); + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + + writtenFiles.clear(); + const palettePath: string = `${packageFolder}/styles/_palette.scss`; + FileSystem.writeFile(palettePath, '$brand: #654321;\n'); + await processor.compileFilesAsync(new Set([palettePath])); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#654321'); + }); + + it.each(['pkg:', ''])('reports invalid export targets for the %j prefix', async (prefix) => { + writePackage({ exports: { '.': { default: './index.js' } } }); + const { logger } = await compilePackageAsync(`${prefix}@test/exported-styles`, { + resolveBareSpecifiersAsPackages: prefix === '' + }); + + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0].message).toContain('not a .scss, .sass, or .css file'); + expect(writtenFiles.size).toBe(0); + }); + + it('reports malformed export maps instead of falling back to the filesystem', async () => { + writePackage({ exports: { '.': './styles/_tokens.scss', sass: './styles/other.scss' } }); + const { logger } = await compilePackageAsync(); + + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0].message).toContain('"exports" cannot contain'); + }); + + it('reports ambiguous extensionless exports', async () => { + writePackage({ + exports: { + './tokens.scss': './styles/_tokens.scss', + './tokens.sass': './styles/indented.sass' + } + }); + const { logger } = await compilePackageAsync('pkg:@test/exported-styles/tokens'); + + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0].message).toContain('Ambiguous Sass exports'); + }); + + it('does not resolve incomplete export targets as partials', async () => { + writePackage({ exports: { '.': { sass: './styles/tokens.scss' } } }); + const { logger } = await compilePackageAsync(); + + expect(logger.errors).toHaveLength(1); + expect(writtenFiles.size).toBe(0); + }); + }); + describe('loadPaths option', () => { it('resolves a bare specifier from a configured load path', async () => { const { processor, logger } = createProcessor(terminalProvider, { 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 c31dfb7402..0bc6c21ca2 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 @@ -1142,6 +1142,641 @@ h1 { } `; +exports[`SassProcessor package exports resolution decodes URL-encoded export targets: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution decodes URL-encoded export targets: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution does not resolve incomplete export targets as partials: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution does not resolve incomplete export targets as partials: written-files 1`] = `Map {}`; + +exports[`SassProcessor package exports resolution falls back to the package index when no entry point matches: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution falls back to the package index when no entry point matches: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #00ff00; +}", +} +`; + +exports[`SassProcessor package exports resolution falls back to the top-level sass field: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution falls back to the top-level sass field: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution falls back to the top-level style field: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution falls back to the top-level style field: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution loads an exported indented Sass stylesheet: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution loads an exported indented Sass stylesheet: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution loads exported plain CSS through meta.load-css(): terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution loads exported plain CSS through meta.load-css(): written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + plain: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".plain { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution matches conditions in manifest order rather than imposing sass-first priority: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution matches conditions in manifest order rather than imposing sass-first priority: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #abcdef; +}", +} +`; + +exports[`SassProcessor package exports resolution prefers a top-level sass entry point over style: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution prefers a top-level sass entry point over style: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution prefers an exact export over a wildcard export: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution prefers an exact export over a wildcard export: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #abcdef; +}", +} +`; + +exports[`SassProcessor package exports resolution preserves physical deep imports when no Sass export matches: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution preserves physical deep imports when no Sass export matches: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution preserves source maps for rules loaded through package exports: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution preserves source maps for rules loaded through package exports: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + palette: string; + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".palette { + color: #123456; +} + +.root { + color: #123456; +} +/*# sourceMappingURL=exports.module.css.map */ +", + "/fake/output/css/exports.module.css.map" => "{\\"version\\":3,\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/_palette.scss\\",\\"fixtures/exports.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AACA;EAAW,OADH;;;ACCR;EAAQ,ODDA\\",\\"sourcesContent\\":[\\"$brand: #123456;\\\\n.palette { color: $brand; }\\\\n\\",\\"@use 'pkg:@test/exported-styles' as tokens;\\\\n.root { color: tokens.$brand; }\\\\n\\"],\\"file\\":\\"exports.module.css\\"}", +} +`; + +exports[`SassProcessor package exports resolution reports ambiguous extensionless exports: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution reports ambiguous extensionless exports: written-files 1`] = `Map {}`; + +exports[`SassProcessor package exports resolution reports invalid export targets for the "" prefix: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution reports invalid export targets for the "" prefix: written-files 1`] = `Map {}`; + +exports[`SassProcessor package exports resolution reports invalid export targets for the "pkg:" prefix: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution reports invalid export targets for the "pkg:" prefix: written-files 1`] = `Map {}`; + +exports[`SassProcessor package exports resolution reports malformed export maps instead of falling back to the filesystem: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution reports malformed export maps instead of falling back to the filesystem: written-files 1`] = `Map {}`; + +exports[`SassProcessor package exports resolution resolves export ./_tokens.scss from subpath tokens: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves export ./_tokens.scss from subpath tokens: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves export ./theme/*.scss from subpath theme/_tokens.scss: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves export ./theme/*.scss from subpath theme/_tokens.scss: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves export ./theme/*.scss from subpath theme/tokens: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves export ./theme/*.scss from subpath theme/tokens: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves export ./theme/_index.scss from subpath theme: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves export ./theme/_index.scss from subpath theme: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves export ./theme/index.scss from subpath theme: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves export ./theme/index.scss from subpath theme: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves export ./tokens from subpath tokens: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves export ./tokens from subpath tokens: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves export ./tokens.scss from subpath tokens: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves export ./tokens.scss from subpath tokens: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports "./styles/_tokens.scss": terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports "./styles/_tokens.scss": written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {".":["./styles/_tokens.scss","./styles/other.scss"]}: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {".":["./styles/_tokens.scss","./styles/other.scss"]}: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {".":{"default":"./styles/_tokens.scss"}}: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {".":{"default":"./styles/_tokens.scss"}}: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {".":{"import":"./index.js","require":"./index.js","sass":"./styles/_tokens.scss"}}: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {".":{"import":"./index.js","require":"./index.js","sass":"./styles/_tokens.scss"}}: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {".":{"sass":"./styles/_tokens.scss","default":"./index.js"}}: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {".":{"sass":"./styles/_tokens.scss","default":"./index.js"}}: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {".":{"style":"./styles/_tokens.scss"}}: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {".":{"style":"./styles/_tokens.scss"}}: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {"sass":"./styles/_tokens.scss","default":"./index.js"}: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution resolves the package root with exports {"sass":"./styles/_tokens.scss","default":"./index.js"}: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution retains dependency tracking for exported files and their relative imports: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution retains dependency tracking for exported files and their relative imports: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #654321; +}", +} +`; + +exports[`SassProcessor package exports resolution supports the "" package-import prefix: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution supports the "" package-import prefix: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution supports the "~" package-import prefix: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution supports the "~" package-import prefix: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + +exports[`SassProcessor package exports resolution supports the "pkg:" package-import prefix: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution supports the "pkg:" package-import prefix: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + exports[`SassProcessor postProcessCssAsync passes compiled CSS through the post-processor callback: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", From 7688e750fedbbbea014805cc9c7709cdbcd6223e Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 21 Oct 2026 13:41:28 -0400 Subject: [PATCH 2/4] [heft-sass-plugin] Refine Sass package resolution and tests Extract a testable package resolver, retain wildcard file contents, and handle wildcard fallback arrays. Check in package fixtures, use asynchronous test I/O, and address package typing and policy feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../rush/browser-approved-packages.json | 4 - .../rush/nonbrowser-approved-packages.json | 4 + heft-plugins/heft-sass-plugin/README.md | 2 + .../src/SassPackageResolver.ts | 117 +++++++++ .../heft-sass-plugin/src/SassProcessor.ts | 152 ++++------- .../src/test/SassPackageResolver.test.ts | 245 ++++++++++++++++++ .../src/test/SassProcessor.test.ts | 106 ++++---- .../__snapshots__/SassProcessor.test.ts.snap | 20 ++ .../test/fixtures/package-exports/.gitignore | 1 + .../@test/exported-styles/_index.scss | 1 + .../@test/exported-styles/package.json | 11 + .../exported-styles/styles/_palette.scss | 1 + .../styles/_spaced tokens.scss | 1 + .../@test/exported-styles/styles/_tokens.scss | 2 + .../exported-styles/styles/indented.sass | 1 + .../@test/exported-styles/styles/other.scss | 1 + .../@test/exported-styles/styles/plain.css | 3 + .../package-exports/src/exports.module.scss | 4 + 18 files changed, 519 insertions(+), 157 deletions(-) create mode 100644 heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts create mode 100644 heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/.gitignore create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/_index.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/package.json create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_palette.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_spaced tokens.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_tokens.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/indented.sass create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/other.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/plain.css create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/src/exports.module.scss diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 2f7d66d04a..9420e1c366 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -118,10 +118,6 @@ "name": "redux", "allowedCategories": [ "libraries", "vscode-extensions" ] }, - { - "name": "resolve-pkg-maps", - "allowedCategories": [ "libraries" ] - }, { "name": "rxjs", "allowedCategories": [ "libraries" ] diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index 1533ec419f..853d3fdc37 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -974,6 +974,10 @@ "name": "resolve", "allowedCategories": [ "libraries" ] }, + { + "name": "resolve-pkg-maps", + "allowedCategories": [ "libraries" ] + }, { "name": "run-scenarios-helpers", "allowedCategories": [ "tests" ] diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index c06adf4462..049e186cee 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -259,6 +259,8 @@ and `require` are not selected. For example: Exported subpaths support Sass's extension, partial, and index-file lookup. Export **targets** must specify complete `.scss`, `.sass`, or `.css` filenames; they are not expanded to partials or given implicit extensions. Multiple matching exports are reported as an ambiguity. +Wildcard targets that do not exist are skipped, including when trying a fallback array. Other +filesystem errors are reported rather than treated as missing stylesheets. When no export matches, a package-root import tries the top-level `sass` and `style` fields, followed by an index stylesheet. A deep import falls back to the corresponding physical path in the package. diff --git a/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts b/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts new file mode 100644 index 0000000000..e340873638 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { resolveExports, type PathConditions } from 'resolve-pkg-maps'; + +import { FileSystem } from '@rushstack/node-core-library'; + +const SASS_FILE_EXTENSIONS: ReadonlySet = new Set(['.scss', '.sass', '.css']); + +export interface ISassPackageExport { + filePath: string; + contents?: Buffer; +} + +export async function resolveSassPackageExportsAsync( + packageFolder: string, + subpath: string, + packageExports: PathConditions +): Promise { + const subpaths: string[] = [subpath]; + // An explicit extension rules out Sass's index-file fallback. + if (!path.posix.extname(subpath)) { + subpaths.push(subpath ? `${subpath}/index` : 'index'); + } + + for (const candidateSubpath of subpaths) { + const matches: Map = new Map(); + for (const candidate of getPackageExportCandidates(candidateSubpath)) { + let targets: string[]; + try { + targets = resolveExports(packageExports, candidate, ['sass', 'style']); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' + ) { + continue; + } else { + throw error; + } + } + + const isPattern: boolean = + !!candidate && !Object.prototype.hasOwnProperty.call(packageExports, `./${candidate}`); + for (const target of targets) { + if (!target.startsWith('./')) { + throw new Error(`Export "${target}" must be relative to the package root at "${packageFolder}".`); + } + + const filePath: string = fileURLToPath(new URL(target, pathToFileURL(`${packageFolder}/`))); + let contents: Buffer | undefined; + if (isPattern) { + try { + contents = await FileSystem.readFileToBufferAsync(filePath); + } catch (error) { + if (FileSystem.isNotExistError(error)) { + continue; + } else { + throw error; + } + } + } + + matches.set(filePath, { filePath, contents }); + break; + } + } + + if (matches.size > 1) { + throw new Error( + `Ambiguous Sass exports for "${subpath || '.'}" in "${packageFolder}": ${[...matches.keys()].join(', ')}` + ); + } + + for (const match of matches.values()) { + if (!SASS_FILE_EXTENSIONS.has(path.extname(match.filePath))) { + throw new Error( + `The export for "${subpath || '.'}" in "${packageFolder}" resolved to "${match.filePath}", ` + + 'which is not a .scss, .sass, or .css file.' + ); + } + + return match; + } + } + + return undefined; +} + +function getPackageExportCandidates(subpath: string): string[] { + if (!subpath) { + return ['']; + } + + const candidates: string[] = [subpath]; + if (!SASS_FILE_EXTENSIONS.has(path.posix.extname(subpath))) { + for (const extension of SASS_FILE_EXTENSIONS) { + candidates.push(`${subpath}${extension}`); + } + } + + if (!path.posix.basename(subpath).startsWith('_')) { + candidates.push( + ...candidates.map((candidate) => { + const dirname: string = path.posix.dirname(candidate); + return `${dirname === '.' ? '' : `${dirname}/`}_${path.posix.basename(candidate)}`; + }) + ); + } + + return candidates; +} diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 8a58581a1c..9bab235afa 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -20,7 +20,7 @@ import { } from 'sass-embedded'; import * as postcss from 'postcss'; import cssModules from 'postcss-modules'; -import { resolveExports, type PathConditions } from 'resolve-pkg-maps'; +import type { PathConditions } from 'resolve-pkg-maps'; import type { IScopedLogger } from '@rushstack/heft'; import { @@ -28,6 +28,7 @@ import { FileError, FileSystem, type IFileSystemWriteFileOptions, + type IPackageJson, Import, JsonFile, type JsonObject, @@ -36,8 +37,15 @@ import { Sort } from '@rushstack/node-core-library'; +import { resolveSassPackageExportsAsync, type ISassPackageExport } from './SassPackageResolver'; + const SIMPLE_IDENTIFIER_REGEX: RegExp = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; -const SASS_FILE_EXTENSIONS: readonly string[] = ['.scss', '.sass', '.css']; + +interface ISassPackageJson extends Omit { + exports?: PathConditions; + sass?: string; + style?: string; +} /** * @public @@ -561,26 +569,34 @@ export class SassProcessor { getRealPath: this.#realpathSync }); const modulePath: string = nodeModulesQuery.slice(linkEnd + 1); - const packageJson: { - exports?: PathConditions | null; - sass?: unknown; - style?: unknown; - } = await JsonFile.loadAsync(path.join(resolvedPackagePath, 'package.json')); - - if (packageJson.exports !== undefined && packageJson.exports !== null) { - const exportedPath: string | undefined = await this.#resolvePackageExportsAsync( + const { + exports: packageExports, + sass, + style + }: ISassPackageJson = await JsonFile.loadAsync(`${resolvedPackagePath}/package.json`); + + if (packageExports) { + const exportedFile: ISassPackageExport | undefined = await resolveSassPackageExportsAsync( resolvedPackagePath, modulePath, - packageJson.exports + packageExports ); - if (exportedPath) { + if (exportedFile) { + const { filePath, contents } = exportedFile; + if (contents) { + const record: IFileRecord = this.#getOrCreateRecord(filePath); + const { content, version } = getFileContentAndVersion(contents); + record.content = content; + record.version = version; + } + // Exports specify complete filenames, without implicit partials or extensions. - return pathToHeftUrl(exportedPath); + return pathToHeftUrl(filePath); } } if (!modulePath) { - for (const field of [packageJson.sass, packageJson.style]) { + for (const field of [sass, style]) { if (typeof field === 'string') { const result: SyncResolution = await this.#canonicalizeHeftUrlAsync( pathToHeftUrl(path.resolve(resolvedPackagePath, field)).href, @@ -593,71 +609,11 @@ export class SassProcessor { } } - const resolvedPath: string = path.join(resolvedPackagePath, modulePath); + const resolvedPath: string = modulePath ? `${resolvedPackagePath}/${modulePath}` : resolvedPackagePath; const heftUrl: string = pathToHeftUrl(resolvedPath).href; return await this.#canonicalizeHeftUrlAsync(heftUrl, context); } - async #resolvePackageExportsAsync( - packageFolder: string, - subpath: string, - packageExports: PathConditions - ): Promise { - const subpaths: string[] = [subpath]; - if (!path.posix.extname(subpath)) { - subpaths.push(subpath ? `${subpath}/index` : 'index'); - } - - for (const candidateSubpath of subpaths) { - const matches: Set = new Set(); - for (const candidate of getPackageExportCandidates(candidateSubpath)) { - let targets: string[]; - try { - targets = resolveExports(packageExports, candidate, ['sass', 'style']); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') { - continue; - } - throw error; - } - - const target: string | undefined = targets[0]; - if (!target) { - continue; - } - if (!target.startsWith('./')) { - throw new Error(`Export "${target}" must be relative to the package root at "${packageFolder}".`); - } - const resolvedPath: string = fileURLToPath( - new URL(target, pathToFileURL(`${packageFolder}${path.sep}`)) - ); - const isPattern: boolean = - !!candidate && !Object.prototype.hasOwnProperty.call(packageExports, `./${candidate}`); - if (isPattern && !(await FileSystem.existsAsync(resolvedPath))) { - continue; - } - matches.add(resolvedPath); - } - - if (matches.size > 1) { - throw new Error( - `Ambiguous Sass exports for "${subpath || '.'}" in "${packageFolder}": ${[...matches].join(', ')}` - ); - } - for (const match of matches) { - if (!SASS_FILE_EXTENSIONS.includes(path.extname(match))) { - throw new Error( - `The export for "${subpath || '.'}" in "${packageFolder}" resolved to "${match}", ` + - 'which is not a .scss, .sass, or .css file.' - ); - } - return match; - } - } - - return undefined; - } - /** * Resolves a `heft:` URL to a physical file path. * @param url - The URL to canonicalize. The URL must be a deep import to a candidate SCSS file. @@ -751,8 +707,9 @@ export class SassProcessor { error.message.includes('Cannot find module ') ) { return null; + } else { + throw error; } - throw error; } } @@ -857,18 +814,7 @@ export class SassProcessor { */ async #readFileContentAsync(absolutePath: string): Promise { const content: Buffer = await FileSystem.readFileToBufferAsync(absolutePath); - let version: string | undefined; - let contentString: string | undefined; - return { - get version() { - version ??= crypto.createHash('sha1').update(content).digest('base64'); - return version; - }, - get content() { - contentString ??= preprocessScss(content); - return contentString; - } - }; + return getFileContentAndVersion(content); } /** @@ -1248,23 +1194,19 @@ function isBareSpecifier(url: string): boolean { return url.length > 0 && !url.startsWith('.') && !url.startsWith('/') && !urlSchemeRegex.test(url); } -function getPackageExportCandidates(subpath: string): string[] { - if (!subpath) { - return ['']; - } - - const candidates: string[] = [subpath]; - if (!SASS_FILE_EXTENSIONS.includes(path.posix.extname(subpath))) { - candidates.push(...SASS_FILE_EXTENSIONS.map((extension) => `${subpath}${extension}`)); - } - if (!path.posix.basename(subpath).startsWith('_')) { - candidates.push( - ...candidates.map((candidate) => - path.posix.join(path.posix.dirname(candidate), `_${path.posix.basename(candidate)}`) - ) - ); - } - return candidates; +function getFileContentAndVersion(content: Buffer): IFileContentAndVersion { + let version: string | undefined; + let contentString: string | undefined; + return { + get version() { + version ??= crypto.createHash('sha1').update(content).digest('base64'); + return version; + }, + get content() { + contentString ??= preprocessScss(content); + return contentString; + } + }; } function getContentsHash(fileName: string, fileContents: string): string { diff --git a/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts new file mode 100644 index 0000000000..4d7023bf52 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { FileSystem, JsonFile } from '@rushstack/node-core-library'; +import { compileStringAsync, NodePackageImporter, type CompileResult } from 'sass-embedded'; +import type { PathConditions } from 'resolve-pkg-maps'; + +import { resolveSassPackageExportsAsync, type ISassPackageExport } from '../SassPackageResolver'; + +const fixtureFolder: string = path.resolve(__dirname, '../../src/test/fixtures/package-exports'); +const packageFolder: string = `${fixtureFolder}/node_modules/@test/exported-styles`; + +interface IResolutionTestCase { + name: string; + exports: PathConditions; + subpath?: string; + expectedFile?: string; +} + +describe(resolveSassPackageExportsAsync.name, () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const testCases: IResolutionTestCase[] = [ + { + name: 'string root export', + exports: './styles/_tokens.scss', + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'conditional root export', + exports: { sass: './styles/_tokens.scss', default: './index.js' }, + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'root fallback array', + exports: { '.': ['./styles/_tokens.scss', './styles/other.scss'] }, + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'manifest condition order', + exports: { '.': { style: './styles/other.scss', sass: './styles/_tokens.scss' } }, + expectedFile: 'styles/other.scss' + }, + { + name: 'JavaScript conditions are skipped', + exports: { '.': { import: './index.js', require: './index.js', sass: './styles/_tokens.scss' } }, + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'default stylesheet condition', + exports: { '.': { default: './styles/plain.css' } }, + expectedFile: 'styles/plain.css' + }, + { + name: 'indented Sass target', + exports: { '.': { sass: './styles/indented.sass' } }, + expectedFile: 'styles/indented.sass' + }, + { + name: 'extensionless subpath', + exports: { './tokens.scss': './styles/_tokens.scss' }, + subpath: 'tokens', + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'partial subpath', + exports: { './_tokens.scss': './styles/_tokens.scss' }, + subpath: 'tokens', + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'index subpath', + exports: { './theme/index.scss': './styles/_tokens.scss' }, + subpath: 'theme', + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'partial index subpath', + exports: { './theme/_index.scss': './styles/_tokens.scss' }, + subpath: 'theme', + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'root index fallback', + exports: { './_index.scss': './_index.scss' }, + expectedFile: '_index.scss' + }, + { + name: 'explicit extension prevents index fallback', + exports: { './theme.scss/index.scss': './styles/_tokens.scss' }, + subpath: 'theme.scss' + }, + { + name: 'missing export', + exports: { './other': './styles/other.scss' }, + subpath: 'tokens' + }, + { + name: 'null export', + exports: { './tokens': null }, + subpath: 'tokens' + }, + { + name: 'no Sass condition', + exports: { '.': { import: './index.js' } } + }, + { + name: 'URL-encoded target', + exports: { '.': './styles/_spaced%20tokens.scss' }, + expectedFile: 'styles/_spaced tokens.scss' + }, + { + name: 'wildcard partial', + exports: { './theme/*.scss': './styles/*.scss' }, + subpath: 'theme/tokens', + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'wildcard fallback array', + exports: { './theme/*.scss': ['./missing/*.scss', './styles/*.scss'] }, + subpath: 'theme/tokens', + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'exact mapping beats a wildcard', + exports: { './theme/*': './styles/*', './theme/_tokens.scss': './styles/other.scss' }, + subpath: 'theme/_tokens.scss', + expectedFile: 'styles/other.scss' + }, + { + name: 'longer wildcard prefix wins', + exports: { './*': './missing/*', './theme/*.scss': './styles/*.scss' }, + subpath: 'theme/_tokens.scss', + expectedFile: 'styles/_tokens.scss' + }, + { + name: 'missing wildcard target', + exports: { './theme/*.scss': './missing/*.scss' }, + subpath: 'theme/tokens' + } + ]; + + it.each(testCases)('resolves $name', async ({ exports, subpath = '', expectedFile }) => { + const result: ISassPackageExport | undefined = await resolveSassPackageExportsAsync( + packageFolder, + subpath, + exports + ); + expect(result?.filePath).toBe(expectedFile ? path.resolve(packageFolder, expectedFile) : undefined); + }); + + it('returns contents already read when resolving a wildcard', async () => { + const result: ISassPackageExport | undefined = await resolveSassPackageExportsAsync( + packageFolder, + 'theme/tokens', + { './theme/*.scss': './styles/*.scss' } + ); + + expect(result?.contents?.toString()).toBe( + await FileSystem.readFileAsync(`${packageFolder}/styles/_tokens.scss`) + ); + }); + + it('defers reading exact targets to the importer, even if they do not exist', async () => { + const readSpy: jest.SpyInstance = jest.spyOn(FileSystem, 'readFileToBufferAsync'); + const result: ISassPackageExport | undefined = await resolveSassPackageExportsAsync(packageFolder, '', { + '.': './styles/missing.scss' + }); + + expect(result?.filePath).toBe(path.resolve(packageFolder, 'styles/missing.scss')); + expect(readSpy).not.toHaveBeenCalled(); + }); + + it('propagates wildcard read errors other than missing files', async () => { + const error: NodeJS.ErrnoException = Object.assign(new Error('Access denied'), { + code: 'EACCES', + errno: -13, + syscall: 'open' + }); + jest.spyOn(FileSystem, 'readFileToBufferAsync').mockRejectedValue(error); + + await expect( + resolveSassPackageExportsAsync(packageFolder, 'theme/tokens', { + './theme/*.scss': './styles/*.scss' + }) + ).rejects.toBe(error); + }); + + it('reports ambiguous extensionless exports', async () => { + await expect( + resolveSassPackageExportsAsync(packageFolder, 'tokens', { + './tokens.scss': './styles/_tokens.scss', + './tokens.sass': './styles/indented.sass' + }) + ).rejects.toThrow('Ambiguous Sass exports'); + }); + + it.each(['./index.js', './styles/tokens'])('rejects non-stylesheet target %s', async (target) => { + await expect(resolveSassPackageExportsAsync(packageFolder, '', { '.': target })).rejects.toThrow( + 'not a .scss, .sass, or .css file' + ); + }); + + it('does not hide malformed export maps', async () => { + await expect( + resolveSassPackageExportsAsync(packageFolder, '', { + '.': './styles/_tokens.scss', + sass: './styles/other.scss' + }) + ).rejects.toThrow('"exports" cannot contain'); + }); + + it('rejects targets outside the package root', async () => { + await expect( + resolveSassPackageExportsAsync(packageFolder, '', { '.': '../outside.scss' }) + ).rejects.toThrow('Invalid "exports" target'); + }); + + it.each(['', 'theme/tokens', 'fallback/tokens'])( + 'agrees with Sass NodePackageImporter for subpath %j', + async (subpath) => { + const { exports }: { exports: PathConditions } = await JsonFile.loadAsync( + `${packageFolder}/package.json` + ); + const result: ISassPackageExport | undefined = await resolveSassPackageExportsAsync( + packageFolder, + subpath, + exports + ); + const specifier: string = `pkg:@test/exported-styles${subpath ? `/${subpath}` : ''}`; + const compiled: CompileResult = await compileStringAsync( + `@use "${specifier}" as tokens; .root { color: tokens.$brand; }`, + { importers: [new NodePackageImporter(fixtureFolder)] } + ); + + expect(compiled.loadedUrls.map((url) => fileURLToPath(url))).toContain(result?.filePath); + expect(compiled.css).toContain('#123456'); + } + ); +}); 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 148d262ddb..f904380c33 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -12,6 +12,8 @@ import { type ICssOutputFolder, type ISassProcessorOptions, SassProcessor } from const projectFolder: string = path.resolve(__dirname, '../..'); const fixturesFolder: string = path.resolve(__dirname, '../../src/test/fixtures'); +// Fixture writes must bypass the per-test mock that captures compiler outputs. +const writeFixtureFileAsync: typeof FileSystem.writeFileAsync = FileSystem.writeFileAsync; /** * Root of a synthesized project used by the bare specifier tests. It is generated on disk rather than @@ -803,11 +805,10 @@ describe(SassProcessor.name, () => { }); describe('bare specifier resolution', () => { - beforeAll(() => { - // Written with the synchronous API because `FileSystem.writeFileAsync` is mocked per-test. - FileSystem.ensureEmptyFolder(bareSpecifierFolder); + beforeAll(async () => { + await FileSystem.ensureEmptyFolderAsync(bareSpecifierFolder); for (const [relativePath, content] of Object.entries(BARE_SPECIFIER_FILES)) { - FileSystem.writeFile(`${bareSpecifierFolder}/${relativePath}`, content, { + await writeFixtureFileAsync(`${bareSpecifierFolder}/${relativePath}`, content, { ensureFolderExists: true }); } @@ -907,8 +908,8 @@ describe(SassProcessor.name, () => { const packageFolder: string = `${testFolder}/node_modules/@test/exported-styles`; const sourcePath: string = `${srcFolder}/exports.module.scss`; - function writePackage(fields: Record): void { - FileSystem.writeFile( + async function writePackageAsync(fields: Record): Promise { + await writeFixtureFileAsync( `${packageFolder}/package.json`, JSON.stringify({ name: '@test/exported-styles', version: '1.0.0', ...fields }), { ensureFolderExists: true } @@ -919,9 +920,11 @@ describe(SassProcessor.name, () => { specifier: string = 'pkg:@test/exported-styles', options: ICreateProcessorOptions = {} ): Promise<{ processor: SassProcessor; logger: MockScopedLogger }> { - FileSystem.writeFile(sourcePath, `@use '${specifier}' as tokens;\n.root { color: tokens.$brand; }\n`, { - ensureFolderExists: true - }); + await writeFixtureFileAsync( + sourcePath, + `@use '${specifier}' as tokens;\n.root { color: tokens.$brand; }\n`, + { ensureFolderExists: true } + ); const result: { processor: SassProcessor; logger: MockScopedLogger } = createProcessor( terminalProvider, { srcFolder, ...options } @@ -930,25 +933,16 @@ describe(SassProcessor.name, () => { return result; } - beforeEach(() => { - FileSystem.ensureEmptyFolder(testFolder); - const files: Record = { - '_index.scss': '$brand: #00ff00;\n', - 'styles/_tokens.scss': "@use './palette';\n$brand: palette.$brand;\n", - 'styles/_palette.scss': '$brand: #123456;\n', - 'styles/_spaced tokens.scss': '$brand: #123456;\n', - 'styles/other.scss': '$brand: #abcdef;\n', - 'styles/plain.css': '.plain { color: #123456; }\n', - 'styles/indented.sass': '$brand: #123456\n', - 'index.js': 'module.exports = {};\n' - }; - for (const [relativePath, content] of Object.entries(files)) { - FileSystem.writeFile(`${packageFolder}/${relativePath}`, content, { ensureFolderExists: true }); - } + beforeEach(async () => { + await FileSystem.ensureEmptyFolderAsync(testFolder); + await FileSystem.copyFilesAsync({ + sourcePath: `${fixturesFolder}/package-exports`, + destinationPath: testFolder + }); }); - afterAll(() => { - FileSystem.deleteFolder(testFolder); + afterAll(async () => { + await FileSystem.deleteFolderAsync(testFolder); }); it.each([ @@ -960,7 +954,7 @@ describe(SassProcessor.name, () => { { '.': { default: './styles/_tokens.scss' } }, { '.': { import: './index.js', require: './index.js', sass: './styles/_tokens.scss' } } ])('resolves the package root with exports %j', async (exports) => { - writePackage({ exports }); + await writePackageAsync({ exports }); const { logger } = await compilePackageAsync(); expect(logger.errors).toHaveLength(0); @@ -968,7 +962,7 @@ describe(SassProcessor.name, () => { }); it('matches conditions in manifest order rather than imposing sass-first priority', async () => { - writePackage({ + await writePackageAsync({ exports: { '.': { style: './styles/other.scss', sass: './styles/_tokens.scss' } } }); const { logger } = await compilePackageAsync(); @@ -986,14 +980,14 @@ describe(SassProcessor.name, () => { ['./theme/*.scss', 'theme/tokens'], ['./theme/*.scss', 'theme/_tokens.scss'] ])('resolves export %s from subpath %s', async (key, subpath) => { - writePackage({ + await writePackageAsync({ exports: { [key]: key.includes('*') ? './styles/_*.scss' : { sass: './styles/_tokens.scss' } } }); // Use an existing filename for both wildcard spellings. if (subpath === 'theme/_tokens.scss') { - writePackage({ exports: { [key]: './styles/*.scss' } }); + await writePackageAsync({ exports: { [key]: './styles/*.scss' } }); } const { logger } = await compilePackageAsync(`pkg:@test/exported-styles/${subpath}`); @@ -1002,7 +996,7 @@ describe(SassProcessor.name, () => { }); it('prefers an exact export over a wildcard export', async () => { - writePackage({ + await writePackageAsync({ exports: { './theme/*': './styles/*', './theme/_tokens.scss': './styles/other.scss' @@ -1014,8 +1008,24 @@ describe(SassProcessor.name, () => { expect(getCssOutput('exports.module.scss')).toContain('#abcdef'); }); + it('reuses a wildcard fallback target already read during resolution', async () => { + await writePackageAsync({ + exports: { './theme/*.scss': ['./missing/*.scss', './styles/*.scss'] } + }); + const readSpy: jest.SpyInstance = jest.spyOn(FileSystem, 'readFileToBufferAsync'); + const { logger } = await compilePackageAsync('pkg:@test/exported-styles/theme/tokens'); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + expect( + readSpy.mock.calls.filter( + ([filePath]) => Path.convertToSlashes(filePath) === `${packageFolder}/styles/_tokens.scss` + ) + ).toHaveLength(1); + }); + it.each(['pkg:', '~', ''])('supports the %j package-import prefix', async (prefix) => { - writePackage({ exports: { './tokens': { sass: './styles/_tokens.scss' } } }); + await writePackageAsync({ exports: { './tokens': { sass: './styles/_tokens.scss' } } }); const { logger } = await compilePackageAsync(`${prefix}@test/exported-styles/tokens`, { resolveBareSpecifiersAsPackages: prefix === '' }); @@ -1025,7 +1035,7 @@ describe(SassProcessor.name, () => { }); it.each(['sass', 'style'])('falls back to the top-level %s field', async (field) => { - writePackage({ + await writePackageAsync({ exports: { '.': { import: './index.js' } }, [field]: './styles/_tokens.scss' }); @@ -1036,7 +1046,7 @@ describe(SassProcessor.name, () => { }); it('prefers a top-level sass entry point over style', async () => { - writePackage({ sass: './styles/_tokens.scss', style: './styles/other.scss' }); + await writePackageAsync({ sass: './styles/_tokens.scss', style: './styles/other.scss' }); const { logger } = await compilePackageAsync(); expect(logger.errors).toHaveLength(0); @@ -1044,7 +1054,7 @@ describe(SassProcessor.name, () => { }); it('preserves physical deep imports when no Sass export matches', async () => { - writePackage({ exports: { '.': { import: './index.js' } } }); + await writePackageAsync({ exports: { '.': { import: './index.js' } } }); const { logger } = await compilePackageAsync('pkg:@test/exported-styles/styles/tokens'); expect(logger.errors).toHaveLength(0); @@ -1052,7 +1062,7 @@ describe(SassProcessor.name, () => { }); it('falls back to the package index when no entry point matches', async () => { - writePackage({ exports: { '.': { import: './index.js' } }, main: './index.js' }); + await writePackageAsync({ exports: { '.': { import: './index.js' } }, main: './index.js' }); const { logger } = await compilePackageAsync(); expect(logger.errors).toHaveLength(0); @@ -1060,7 +1070,7 @@ describe(SassProcessor.name, () => { }); it('loads an exported indented Sass stylesheet', async () => { - writePackage({ exports: { '.': { sass: './styles/indented.sass' } } }); + await writePackageAsync({ exports: { '.': { sass: './styles/indented.sass' } } }); const { logger } = await compilePackageAsync(); expect(logger.errors).toHaveLength(0); @@ -1068,7 +1078,7 @@ describe(SassProcessor.name, () => { }); it('decodes URL-encoded export targets', async () => { - writePackage({ exports: { '.': { sass: './styles/_spaced%20tokens.scss' } } }); + await writePackageAsync({ exports: { '.': { sass: './styles/_spaced%20tokens.scss' } } }); const { logger } = await compilePackageAsync(); expect(logger.errors).toHaveLength(0); @@ -1076,9 +1086,9 @@ describe(SassProcessor.name, () => { }); it('preserves source maps for rules loaded through package exports', async () => { - writePackage({ exports: { '.': { sass: './styles/_tokens.scss' } } }); + await writePackageAsync({ exports: { '.': { sass: './styles/_tokens.scss' } } }); const paletteContent: string = '$brand: #123456;\n.palette { color: $brand; }\n'; - FileSystem.writeFile(`${packageFolder}/styles/_palette.scss`, paletteContent); + await writeFixtureFileAsync(`${packageFolder}/styles/_palette.scss`, paletteContent); const { logger } = await compilePackageAsync('pkg:@test/exported-styles', { sourceMap: true }); const sourceMap: { sources: string[]; sourcesContent: string[] } = JSON.parse( getWrittenFile('exports.module.css.map') @@ -1090,8 +1100,8 @@ describe(SassProcessor.name, () => { }); it('loads exported plain CSS through meta.load-css()', async () => { - writePackage({ exports: { './plain': { style: './styles/plain.css' } } }); - FileSystem.writeFile( + await writePackageAsync({ exports: { './plain': { style: './styles/plain.css' } } }); + await writeFixtureFileAsync( sourcePath, '@use "sass:meta";\n@include meta.load-css("pkg:@test/exported-styles/plain");\n', { ensureFolderExists: true } @@ -1104,14 +1114,14 @@ describe(SassProcessor.name, () => { }); it('retains dependency tracking for exported files and their relative imports', async () => { - writePackage({ exports: { '.': { sass: './styles/_tokens.scss' } } }); + await writePackageAsync({ exports: { '.': { sass: './styles/_tokens.scss' } } }); const { processor, logger } = await compilePackageAsync(); expect(logger.errors).toHaveLength(0); expect(getCssOutput('exports.module.scss')).toContain('#123456'); writtenFiles.clear(); const palettePath: string = `${packageFolder}/styles/_palette.scss`; - FileSystem.writeFile(palettePath, '$brand: #654321;\n'); + await writeFixtureFileAsync(palettePath, '$brand: #654321;\n'); await processor.compileFilesAsync(new Set([palettePath])); expect(logger.errors).toHaveLength(0); @@ -1119,7 +1129,7 @@ describe(SassProcessor.name, () => { }); it.each(['pkg:', ''])('reports invalid export targets for the %j prefix', async (prefix) => { - writePackage({ exports: { '.': { default: './index.js' } } }); + await writePackageAsync({ exports: { '.': { default: './index.js' } } }); const { logger } = await compilePackageAsync(`${prefix}@test/exported-styles`, { resolveBareSpecifiersAsPackages: prefix === '' }); @@ -1130,7 +1140,7 @@ describe(SassProcessor.name, () => { }); it('reports malformed export maps instead of falling back to the filesystem', async () => { - writePackage({ exports: { '.': './styles/_tokens.scss', sass: './styles/other.scss' } }); + await writePackageAsync({ exports: { '.': './styles/_tokens.scss', sass: './styles/other.scss' } }); const { logger } = await compilePackageAsync(); expect(logger.errors).toHaveLength(1); @@ -1138,7 +1148,7 @@ describe(SassProcessor.name, () => { }); it('reports ambiguous extensionless exports', async () => { - writePackage({ + await writePackageAsync({ exports: { './tokens.scss': './styles/_tokens.scss', './tokens.sass': './styles/indented.sass' @@ -1151,7 +1161,7 @@ describe(SassProcessor.name, () => { }); it('does not resolve incomplete export targets as partials', async () => { - writePackage({ exports: { '.': { sass: './styles/tokens.scss' } } }); + await writePackageAsync({ exports: { '.': { sass: './styles/tokens.scss' } } }); const { logger } = await compilePackageAsync(); expect(logger.errors).toHaveLength(1); 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 0bc6c21ca2..ca5f04c1b5 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 @@ -1717,6 +1717,26 @@ export default styles;", } `; +exports[`SassProcessor package exports resolution reuses a wildcard fallback target already read during resolution: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution reuses a wildcard fallback target already read during resolution: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #123456; +}", +} +`; + exports[`SassProcessor package exports resolution supports the "" package-import prefix: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/.gitignore b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/.gitignore new file mode 100644 index 0000000000..cf4bab9ddd --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/.gitignore @@ -0,0 +1 @@ +!node_modules diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/_index.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/_index.scss new file mode 100644 index 0000000000..8651677338 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/_index.scss @@ -0,0 +1 @@ +$brand: #00ff00; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/package.json b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/package.json new file mode 100644 index 0000000000..99529ffdd4 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/exported-styles", + "version": "1.0.0", + "exports": { + ".": { + "sass": "./styles/_tokens.scss" + }, + "./theme/*.scss": "./styles/*.scss", + "./fallback/*.scss": ["./missing/*.scss", "./styles/*.scss"] + } +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_palette.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_palette.scss new file mode 100644 index 0000000000..dc30055447 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_palette.scss @@ -0,0 +1 @@ +$brand: #123456; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_spaced tokens.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_spaced tokens.scss new file mode 100644 index 0000000000..dc30055447 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_spaced tokens.scss @@ -0,0 +1 @@ +$brand: #123456; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_tokens.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_tokens.scss new file mode 100644 index 0000000000..98a48613e6 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/_tokens.scss @@ -0,0 +1,2 @@ +@use './palette'; +$brand: palette.$brand; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/indented.sass b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/indented.sass new file mode 100644 index 0000000000..cce5561477 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/indented.sass @@ -0,0 +1 @@ +$brand: #123456 diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/other.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/other.scss new file mode 100644 index 0000000000..dec0a5638e --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/other.scss @@ -0,0 +1 @@ +$brand: #abcdef; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/plain.css b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/plain.css new file mode 100644 index 0000000000..a06dc1b146 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/plain.css @@ -0,0 +1,3 @@ +.plain { + color: #123456; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/src/exports.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/src/exports.module.scss new file mode 100644 index 0000000000..633de980a0 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/src/exports.module.scss @@ -0,0 +1,4 @@ +@use 'pkg:@test/exported-styles' as tokens; +.root { + color: tokens.$brand; +} From e399e2ccfc504eda2400ce5873a972e754f0a919 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 21 Oct 2026 14:40:08 -0400 Subject: [PATCH 3/4] [heft-sass-plugin] Finalize resolver review and Windows tests Keep Sass manifest types with the resolver and clarify preloaded content caching. Compare importer results using platform-aware path semantics so Windows drive-letter casing does not cause false test failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../heft-sass-plugin/src/SassPackageResolver.ts | 9 ++++++++- .../heft-sass-plugin/src/SassProcessor.ts | 15 ++++++--------- .../src/test/SassPackageResolver.test.ts | 8 +++++++- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts b/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts index e340873638..7c05a51699 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts @@ -6,10 +6,17 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { resolveExports, type PathConditions } from 'resolve-pkg-maps'; -import { FileSystem } from '@rushstack/node-core-library'; +import { FileSystem, type IPackageJson } from '@rushstack/node-core-library'; const SASS_FILE_EXTENSIONS: ReadonlySet = new Set(['.scss', '.sass', '.css']); +// IPackageJson does not currently cover arbitrary export conditions or nested fallback arrays. +export interface ISassPackageJson extends Omit { + exports?: PathConditions; + sass?: string; + style?: string; +} + export interface ISassPackageExport { filePath: string; contents?: Buffer; diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 9bab235afa..2b1edcb1aa 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -20,7 +20,6 @@ import { } from 'sass-embedded'; import * as postcss from 'postcss'; import cssModules from 'postcss-modules'; -import type { PathConditions } from 'resolve-pkg-maps'; import type { IScopedLogger } from '@rushstack/heft'; import { @@ -28,7 +27,6 @@ import { FileError, FileSystem, type IFileSystemWriteFileOptions, - type IPackageJson, Import, JsonFile, type JsonObject, @@ -37,16 +35,14 @@ import { Sort } from '@rushstack/node-core-library'; -import { resolveSassPackageExportsAsync, type ISassPackageExport } from './SassPackageResolver'; +import { + resolveSassPackageExportsAsync, + type ISassPackageExport, + type ISassPackageJson +} from './SassPackageResolver'; const SIMPLE_IDENTIFIER_REGEX: RegExp = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; -interface ISassPackageJson extends Omit { - exports?: PathConditions; - sass?: string; - style?: string; -} - /** * @public */ @@ -584,6 +580,7 @@ export class SassProcessor { if (exportedFile) { const { filePath, contents } = exportedFile; if (contents) { + // Seed the loader's content cache with the wildcard candidate already read by the resolver. const record: IFileRecord = this.#getOrCreateRecord(filePath); const { content, version } = getFileContentAndVersion(contents); record.content = content; diff --git a/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts index 4d7023bf52..a554e56e38 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts @@ -232,13 +232,19 @@ describe(resolveSassPackageExportsAsync.name, () => { subpath, exports ); + if (!result) { + throw new Error(`Expected a Sass export for "${subpath || '.'}".`); + } + const specifier: string = `pkg:@test/exported-styles${subpath ? `/${subpath}` : ''}`; const compiled: CompileResult = await compileStringAsync( `@use "${specifier}" as tokens; .root { color: tokens.$brand; }`, { importers: [new NodePackageImporter(fixtureFolder)] } ); - expect(compiled.loadedUrls.map((url) => fileURLToPath(url))).toContain(result?.filePath); + expect(compiled.loadedUrls.map((url) => path.relative(result.filePath, fileURLToPath(url)))).toContain( + '' + ); expect(compiled.css).toContain('#123456'); } ); From 2ac063549781e74ba03aeec8f99696828ec204a6 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 21 Oct 2026 16:44:14 -0400 Subject: [PATCH 4/4] [heft-sass-plugin] Track manifests and resolve wildcard directories Invalidate stylesheet consumers when package entry points change, including persisted caches and watch mode. Treat wildcard directory candidates as non-matches so index exports remain reachable, and add regression coverage against Sass's own importer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- heft-plugins/heft-sass-plugin/README.md | 6 +- .../src/SassPackageResolver.ts | 2 +- .../heft-sass-plugin/src/SassPlugin.ts | 14 ++ .../heft-sass-plugin/src/SassProcessor.ts | 97 +++++++--- .../src/test/SassPackageResolver.test.ts | 47 +++++ .../src/test/SassPlugin.test.ts | 122 ++++++++++++ .../src/test/SassProcessor.test.ts | 73 ++++++++ .../__snapshots__/SassProcessor.test.ts.snap | 176 ++++++++++++++++++ .../@test/exported-styles/package.json | 4 +- .../exported-styles/styles/theme/_index.scss | 1 + .../src/_package-consumer.scss | 1 + 11 files changed, 517 insertions(+), 26 deletions(-) create mode 100644 heft-plugins/heft-sass-plugin/src/test/SassPlugin.test.ts create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/theme/_index.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/src/_package-consumer.scss diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index 049e186cee..fe276a33d0 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -301,7 +301,11 @@ 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. +The plugin tracks inter-file dependencies (via `@use`, `@forward`, and `@import`) and the package manifests +used to resolve package imports. Changes to `exports`, `sass`, or `style` entry points invalidate their +consumers, including when restoring the incremental cache. Package manifests are also watched for changes, +but are never compiled as stylesheets. Only changed files and their consumers are recompiled, keeping +`heft build --watch` fast even in large projects. ## Plugin accessor API diff --git a/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts b/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts index 7c05a51699..380aa40204 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts @@ -65,7 +65,7 @@ export async function resolveSassPackageExportsAsync( try { contents = await FileSystem.readFileToBufferAsync(filePath); } catch (error) { - if (FileSystem.isNotExistError(error)) { + if (FileSystem.isNotExistError(error) || FileSystem.isDirectoryError(error)) { continue; } else { throw error; diff --git a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts index 1429e775c0..bd8d71e04d 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts @@ -207,7 +207,21 @@ export default class SassPlugin implements IHeftPlugin { } } + const watchedManifests: Set = new Set(sassProcessor.packageManifestPaths); + for (const manifest of watchedManifests) { + const state: IWatchedFileState = await runOptions.watchFs.getStateAndTrackAsync(manifest); + if (state.changed) { + modifiedFiles.add(manifest); + } + } + await compileFilesAsync(sassProcessor, modifiedFiles, true); + + for (const manifest of sassProcessor.packageManifestPaths) { + if (!watchedManifests.has(manifest)) { + await runOptions.watchFs.getStateAndTrackAsync(manifest); + } + } } ); } diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 2b1edcb1aa..e2e8d9057e 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -184,6 +184,7 @@ interface IFileRecord { index: number; isPartial: boolean; isModule: boolean; + isResolutionDependency: boolean; relativePath: string; version: string; content: string | undefined; @@ -197,6 +198,7 @@ interface ISerializedFileRecord { version: string; cssVersion?: string | undefined; dependencies: number[]; + isResolutionDependency?: boolean; } /** @@ -237,6 +239,7 @@ export class SassProcessor { // Map of input file path -> record readonly #fileInfo: Map; readonly #resolutions: Map; + readonly #packageManifestsByStylesheet: Map>; readonly #isFileModule: (filePath: string) => boolean; readonly #loadPaths: readonly string[]; @@ -299,6 +302,7 @@ export class SassProcessor { this.#loadPaths = options.loadPaths ?? []; this.#resolveBareSpecifiersAsPackages = options.resolveBareSpecifiersAsPackages ?? false; this.#resolutions = new Map(); + this.#packageManifestsByStylesheet = new Map(); this.#options = options; this.#realpathSync = new RealNodeModulePathResolver().realNodeModulePath; this.#scssOptions = { @@ -315,8 +319,17 @@ export class SassProcessor { }; } + public get packageManifestPaths(): readonly string[] { + return [...this.#fileInfo.values()] + .filter((record) => record.isResolutionDependency) + .map((record) => record.absolutePath); + } + public async loadCacheAsync(tempFolderPath: string): Promise { - const configHash: string = getContentsHash('sass.json', JSON.stringify(this.#options)).slice(0, 8); + const configHash: string = getContentsHash( + 'sass.json', + JSON.stringify({ version: 2, options: this.#options }) + ).slice(0, 8); this.#configFilePath = path.join(tempFolderPath, `sass_${configHash}.json`); @@ -333,6 +346,7 @@ export class SassProcessor { public async compileFilesAsync(filepaths: Set): Promise { // Incremental resolve is complicated, so just clear it for now this.#resolutions.clear(); + this.#packageManifestsByStylesheet.clear(); // Expand affected files using dependency graph // If this is the initial compilation, the graph will be empty, so this will no-op' @@ -386,8 +400,8 @@ export class SassProcessor { } for (const record of affectedRecords) { - if (record.isPartial) { - // Filter out partials before compilation so we don't pay async overhead when skipping them + if (record.isPartial || record.isResolutionDependency) { + // Partials and resolution metadata invalidate consumers but are not compilation entry points. affectedRecords.delete(record); } } @@ -565,12 +579,23 @@ export class SassProcessor { getRealPath: this.#realpathSync }); const modulePath: string = nodeModulesQuery.slice(linkEnd + 1); + const manifestPath: string = path.resolve(resolvedPackagePath, 'package.json'); + const manifestContents: Buffer = await FileSystem.readFileToBufferAsync(manifestPath); + const { content: manifestContent, version: manifestVersion } = getFileContentAndVersion( + manifestContents, + false + ); + const manifestRecord: IFileRecord = this.#getOrCreateRecord(manifestPath, true); + manifestRecord.content = manifestContent; + manifestRecord.version = manifestVersion; + const { exports: packageExports, sass, style - }: ISassPackageJson = await JsonFile.loadAsync(`${resolvedPackagePath}/package.json`); + }: ISassPackageJson = JsonFile.parseString(manifestRecord.content); + let resolution: SyncResolution = null; if (packageExports) { const exportedFile: ISassPackageExport | undefined = await resolveSassPackageExportsAsync( resolvedPackagePath, @@ -588,27 +613,40 @@ export class SassProcessor { } // Exports specify complete filenames, without implicit partials or extensions. - return pathToHeftUrl(filePath); + resolution = pathToHeftUrl(filePath); } } - if (!modulePath) { + if (!resolution && !modulePath) { for (const field of [sass, style]) { if (typeof field === 'string') { - const result: SyncResolution = await this.#canonicalizeHeftUrlAsync( + resolution = await this.#canonicalizeHeftUrlAsync( pathToHeftUrl(path.resolve(resolvedPackagePath, field)).href, context ); - if (result) { - return result; + if (resolution) { + break; } } } } - const resolvedPath: string = modulePath ? `${resolvedPackagePath}/${modulePath}` : resolvedPackagePath; - const heftUrl: string = pathToHeftUrl(resolvedPath).href; - return await this.#canonicalizeHeftUrlAsync(heftUrl, context); + if (!resolution) { + const resolvedPath: string = modulePath ? `${resolvedPackagePath}/${modulePath}` : resolvedPackagePath; + resolution = await this.#canonicalizeHeftUrlAsync(pathToHeftUrl(resolvedPath).href, context); + } + + if (resolution) { + let manifests: Set | undefined = this.#packageManifestsByStylesheet.get(resolution.href); + if (!manifests) { + manifests = new Set(); + this.#packageManifestsByStylesheet.set(resolution.href, manifests); + } + + manifests.add(manifestRecord); + } + + return resolution; } /** @@ -747,7 +785,8 @@ export class SassProcessor { relativePath: record.relativePath, version: record.version, cssVersion: record.cssVersion, - dependencies: Array.from(record.dependencies, (dependency) => dependency.index) + dependencies: Array.from(record.dependencies, (dependency) => dependency.index), + isResolutionDependency: record.isResolutionDependency || undefined }; }); @@ -765,20 +804,22 @@ export class SassProcessor { const records: IFileRecord[] = []; const buildFolder: string = this.#options.buildFolder; for (const record of serializedRecords) { - const { relativePath, version, cssVersion } = record; + const { relativePath, version, cssVersion, isResolutionDependency = false } = record; // relativePath may start with `../` or similar, so need to use a library join function. const absolutePath: string = path.resolve(buildFolder, relativePath); const url: URL = pathToHeftUrl(absolutePath); const isPartial: boolean = isSassPartial(absolutePath); // SCSS partials are not modules, insofar as they cannot be imported directly. - const isModule: boolean = isPartial ? false : this.#isFileModule(absolutePath); + const isModule: boolean = + isPartial || isResolutionDependency ? false : this.#isFileModule(absolutePath); const fileRecord: IFileRecord = { absolutePath, url, isPartial, isModule, + isResolutionDependency, index: records.length, relativePath, version, @@ -811,7 +852,8 @@ export class SassProcessor { */ async #readFileContentAsync(absolutePath: string): Promise { const content: Buffer = await FileSystem.readFileToBufferAsync(absolutePath); - return getFileContentAndVersion(content); + const isStylesheet: boolean = !this.#fileInfo.get(absolutePath)?.isResolutionDependency; + return getFileContentAndVersion(content, isStylesheet); } /** @@ -819,18 +861,19 @@ export class SassProcessor { * @param filePath - The file path to get or create a record for * @returns The tracking record for the specified file */ - #getOrCreateRecord(filePath: string): IFileRecord { + #getOrCreateRecord(filePath: string, isResolutionDependency: boolean = false): IFileRecord { filePath = path.resolve(filePath); let record: IFileRecord | undefined = this.#fileInfo.get(filePath); if (!record) { const isPartial: boolean = isSassPartial(filePath); - const isModule: boolean = isPartial ? false : this.#isFileModule(filePath); + const isModule: boolean = isPartial || isResolutionDependency ? false : this.#isFileModule(filePath); const url: URL = pathToHeftUrl(filePath); record = { absolutePath: filePath, url, isPartial, isModule, + isResolutionDependency, index: this.#fileInfo.size, relativePath: Path.convertToSlashes(path.relative(this.#options.buildFolder, filePath)), version: '', @@ -876,12 +919,20 @@ export class SassProcessor { } // Register any @import files as dependencies. + for (const dependency of record.dependencies) { + dependency.consumers.delete(record); + } + record.dependencies.clear(); for (const dependency of result.loadedUrls) { const dependencyPath: string = heftUrlToPath(dependency.href); - const dependencyRecord: IFileRecord = this.#getOrCreateRecord(dependencyPath); - record.dependencies.add(dependencyRecord); - dependencyRecord.consumers.add(record); + for (const dependencyRecord of [ + this.#getOrCreateRecord(dependencyPath), + ...(this.#packageManifestsByStylesheet.get(dependency.href) || []) + ]) { + record.dependencies.add(dependencyRecord); + dependencyRecord.consumers.add(record); + } } let css: string = result.css.toString(); @@ -1191,7 +1242,7 @@ function isBareSpecifier(url: string): boolean { return url.length > 0 && !url.startsWith('.') && !url.startsWith('/') && !urlSchemeRegex.test(url); } -function getFileContentAndVersion(content: Buffer): IFileContentAndVersion { +function getFileContentAndVersion(content: Buffer, isStylesheet: boolean = true): IFileContentAndVersion { let version: string | undefined; let contentString: string | undefined; return { @@ -1200,7 +1251,7 @@ function getFileContentAndVersion(content: Buffer): IFileContentAndVersion { return version; }, get content() { - contentString ??= preprocessScss(content); + contentString ??= isStylesheet ? preprocessScss(content) : content.toString('utf8'); return contentString; } }; diff --git a/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts index a554e56e38..c19b690604 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts @@ -95,6 +95,17 @@ describe(resolveSassPackageExportsAsync.name, () => { exports: { './theme.scss/index.scss': './styles/_tokens.scss' }, subpath: 'theme.scss' }, + { + name: 'dotted subpath follows Sass by preventing index fallback', + exports: { './theme.v1/_index.scss': './styles/_tokens.scss' }, + subpath: 'theme.v1' + }, + { + name: 'wildcard directory index', + exports: { './*': './styles/*' }, + subpath: 'theme', + expectedFile: 'styles/theme/_index.scss' + }, { name: 'missing export', exports: { './other': './styles/other.scss' }, @@ -248,4 +259,40 @@ describe(resolveSassPackageExportsAsync.name, () => { expect(compiled.css).toContain('#123456'); } ); + + it('agrees with Sass that a dotted subpath does not use an exported index fallback', async () => { + const { exports }: { exports: PathConditions } = await JsonFile.loadAsync( + `${packageFolder}/package.json` + ); + await expect(resolveSassPackageExportsAsync(packageFolder, 'theme.v1', exports)).resolves.toBeUndefined(); + await expect( + compileStringAsync('@use "pkg:@test/exported-styles/theme.v1";', { + importers: [new NodePackageImporter(fixtureFolder)] + }) + ).rejects.toThrow("Can't find stylesheet to import"); + }); + + it('agrees with Sass when a wildcard first resolves to a directory', async () => { + const { exports }: { exports: PathConditions } = await JsonFile.loadAsync( + `${packageFolder}/package.json` + ); + const result: ISassPackageExport | undefined = await resolveSassPackageExportsAsync( + packageFolder, + 'directory/theme', + exports + ); + if (!result) { + throw new Error('Expected the wildcard directory index to resolve.'); + } + + const compiled: CompileResult = await compileStringAsync( + '@use "pkg:@test/exported-styles/directory/theme" as theme; .root { color: theme.$brand; }', + { importers: [new NodePackageImporter(fixtureFolder)] } + ); + + expect(compiled.loadedUrls.map((url) => path.relative(result.filePath, fileURLToPath(url)))).toContain( + '' + ); + expect(compiled.css).toContain('#789abc'); + }); }); diff --git a/heft-plugins/heft-sass-plugin/src/test/SassPlugin.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassPlugin.test.ts new file mode 100644 index 0000000000..24ce26e201 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/SassPlugin.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { AsyncParallelHook, AsyncSeriesWaterfallHook } from 'tapable'; + +import type { + HeftConfiguration, + IHeftTaskHooks, + IHeftTaskSession, + IHeftTaskRunHookOptions, + IHeftTaskRunIncrementalHookOptions, + IHeftTaskFileOperations, + IWatchFileSystem +} from '@rushstack/heft'; +import { MockScopedLogger } from '@rushstack/heft/lib/pluginFramework/logging/MockScopedLogger'; +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import SassPlugin from '../SassPlugin'; +import { SassProcessor } from '../SassProcessor'; + +const buildFolder: string = path.resolve(__dirname, 'project'); +const sourcePath: string = path.resolve(buildFolder, 'src/styles.module.scss'); +const manifestPath: string = path.resolve(buildFolder, 'node_modules/styles/package.json'); + +describe('SassPlugin manifest watching', () => { + let hooks: IHeftTaskHooks; + let runOptions: IHeftTaskRunIncrementalHookOptions; + let compileSpy: jest.SpyInstance; + let manifestPathsSpy: jest.SpyInstance; + let watchGlobMock: jest.Mock; + let trackFileMock: jest.Mock; + + beforeEach(() => { + hooks = { + run: new AsyncParallelHook(['options']), + runIncremental: new AsyncParallelHook(['options']), + registerFileOperations: new AsyncSeriesWaterfallHook(['operations']) + }; + const logger: MockScopedLogger = new MockScopedLogger(new Terminal(new StringBufferTerminalProvider())); + const session: Pick = { + hooks, + logger, + tempFolderPath: path.resolve(buildFolder, 'temp/sass') + }; + const configuration: Pick< + HeftConfiguration, + 'numberOfCores' | 'slashNormalizedBuildFolderPath' | 'tryLoadProjectConfigurationFileAsync' + > = { + numberOfCores: 1, + slashNormalizedBuildFolderPath: buildFolder, + tryLoadProjectConfigurationFileAsync: jest.fn().mockResolvedValue(undefined) + }; + jest.spyOn(SassProcessor.prototype, 'loadCacheAsync').mockResolvedValue(undefined); + compileSpy = jest.spyOn(SassProcessor.prototype, 'compileFilesAsync').mockResolvedValue(undefined); + manifestPathsSpy = jest.spyOn(SassProcessor.prototype, 'packageManifestPaths', 'get').mockReturnValue([]); + new SassPlugin().apply(session as IHeftTaskSession, configuration as HeftConfiguration); + + watchGlobMock = jest.fn().mockResolvedValue(new Map()); + trackFileMock = jest.fn().mockResolvedValue({ changed: false }); + const watchFs: IWatchFileSystem = { + readdirSync: jest.fn(), + readdir: jest.fn(), + lstat: jest.fn(), + lstatSync: jest.fn(), + stat: jest.fn(), + statSync: jest.fn(), + getStateAndTrackAsync: trackFileMock, + getStateAndTrack: jest.fn() + }; + runOptions = { + abortSignal: new AbortController().signal, + globAsync: jest.fn(), + watchGlobAsync: watchGlobMock, + requestRun: jest.fn(), + watchFs + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('registers newly discovered manifests and recompiles on manifest-only changes', async () => { + watchGlobMock.mockResolvedValue(new Map([[sourcePath, { changed: true }]])); + compileSpy.mockImplementation(async () => { + manifestPathsSpy.mockReturnValue([manifestPath]); + }); + + await hooks.runIncremental.promise(runOptions); + + expect(compileSpy).toHaveBeenLastCalledWith(new Set([sourcePath])); + expect(trackFileMock).toHaveBeenCalledWith(manifestPath); + + watchGlobMock.mockResolvedValue(new Map([[sourcePath, { changed: false }]])); + trackFileMock.mockResolvedValue({ changed: true }); + await hooks.runIncremental.promise(runOptions); + + expect(compileSpy).toHaveBeenCalledTimes(2); + expect(compileSpy).toHaveBeenLastCalledWith(new Set([manifestPath])); + }); + + it('watches manifests restored from the cache before checking for changes', async () => { + manifestPathsSpy.mockReturnValue([manifestPath]); + trackFileMock.mockResolvedValue({ changed: true }); + + await hooks.runIncremental.promise(runOptions); + + expect(trackFileMock).toHaveBeenCalledWith(manifestPath); + expect(compileSpy).toHaveBeenCalledWith(new Set([manifestPath])); + }); + + it('does not compile unchanged manifests as standalone stylesheets', async () => { + manifestPathsSpy.mockReturnValue([manifestPath]); + + await hooks.runIncremental.promise(runOptions); + + expect(trackFileMock).toHaveBeenCalledWith(manifestPath); + expect(compileSpy).not.toHaveBeenCalled(); + }); +}); 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 f904380c33..b5eefedfad 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -1128,6 +1128,79 @@ describe(SassProcessor.name, () => { expect(getCssOutput('exports.module.scss')).toContain('#654321'); }); + describe.each(['exports', 'sass', 'style'])('manifest-only %s changes', (field) => { + it.each([false, true])('retargets imports with a persisted cache: %s', async (persistCache) => { + const getEntryPoint = (target: string): Record => + field === 'exports' ? { exports: { '.': { sass: target } } } : { [field]: target }; + await writePackageAsync(getEntryPoint('./styles/_tokens.scss')); + let { processor, logger } = createProcessor(terminalProvider, { srcFolder }); + const cacheFolder: string = `${testFolder}/cache`; + if (persistCache) { + await processor.loadCacheAsync(cacheFolder); + } + + await processor.compileFilesAsync(new Set([sourcePath])); + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + expect(processor.packageManifestPaths).toContain(path.resolve(packageFolder, 'package.json')); + + if (persistCache) { + const cacheFile: [string, string] | undefined = [...writtenFiles].find(([filePath]) => + filePath.startsWith(`${cacheFolder}/`) + ); + if (!cacheFile) { + throw new Error('Expected the processor to write its persistent cache.'); + } + + await writeFixtureFileAsync(cacheFile[0], cacheFile[1], { ensureFolderExists: true }); + ({ processor, logger } = createProcessor(terminalProvider, { srcFolder })); + await processor.loadCacheAsync(cacheFolder); + expect(processor.packageManifestPaths).toContain(path.resolve(packageFolder, 'package.json')); + } + + writtenFiles.clear(); + await writePackageAsync(getEntryPoint('./styles/other.scss')); + await processor.compileFilesAsync(new Set([sourcePath])); + for (const filePath of writtenFiles.keys()) { + if (filePath.startsWith(`${cacheFolder}/`)) { + writtenFiles.delete(filePath); + } + } + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#abcdef'); + expect(getAllWrittenPathsMatching('package.json.d.ts')).toHaveLength(0); + }); + }); + + it('recompiles consumers when only a package manifest is reported as changed', async () => { + await writePackageAsync({ exports: { '.': './styles/_tokens.scss' } }); + const { processor, logger } = await compilePackageAsync(); + expect(logger.errors).toHaveLength(0); + writtenFiles.clear(); + + await writePackageAsync({ exports: { '.': './styles/other.scss' } }); + await processor.compileFilesAsync(new Set([path.resolve(packageFolder, 'package.json')])); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#abcdef'); + expect(getAllWrittenPathsMatching('package.json.d.ts')).toHaveLength(0); + }); + + it('tracks manifests for package imports made by a dependency stylesheet', async () => { + await writePackageAsync({ exports: { '.': './styles/_tokens.scss' } }); + const { processor, logger } = await compilePackageAsync('./package-consumer'); + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#123456'); + writtenFiles.clear(); + + await writePackageAsync({ exports: { '.': './styles/other.scss' } }); + await processor.compileFilesAsync(new Set([sourcePath])); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('exports.module.scss')).toContain('#abcdef'); + }); + it.each(['pkg:', ''])('reports invalid export targets for the %j prefix', async (prefix) => { await writePackageAsync({ exports: { '.': { default: './index.js' } } }); const { logger } = await compilePackageAsync(`${prefix}@test/exported-styles`, { 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 ca5f04c1b5..586fa18865 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 @@ -1271,6 +1271,138 @@ export default styles;", } `; +exports[`SassProcessor package exports resolution manifest-only exports changes retargets imports with a persisted cache: false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution manifest-only exports changes retargets imports with a persisted cache: false: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #abcdef; +}", +} +`; + +exports[`SassProcessor package exports resolution manifest-only exports changes retargets imports with a persisted cache: true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution manifest-only exports changes retargets imports with a persisted cache: true: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #abcdef; +}", +} +`; + +exports[`SassProcessor package exports resolution manifest-only sass changes retargets imports with a persisted cache: false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution manifest-only sass changes retargets imports with a persisted cache: false: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #abcdef; +}", +} +`; + +exports[`SassProcessor package exports resolution manifest-only sass changes retargets imports with a persisted cache: true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution manifest-only sass changes retargets imports with a persisted cache: true: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #abcdef; +}", +} +`; + +exports[`SassProcessor package exports resolution manifest-only style changes retargets imports with a persisted cache: false: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution manifest-only style changes retargets imports with a persisted cache: false: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #abcdef; +}", +} +`; + +exports[`SassProcessor package exports resolution manifest-only style changes retargets imports with a persisted cache: true: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution manifest-only style changes retargets imports with a persisted cache: true: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #abcdef; +}", +} +`; + exports[`SassProcessor package exports resolution matches conditions in manifest order rather than imposing sass-first priority: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -1379,6 +1511,28 @@ export default styles;", } `; +exports[`SassProcessor package exports resolution recompiles consumers when only a package manifest is reported as changed: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution recompiles consumers when only a package manifest is reported as changed: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #abcdef; +}", +} +`; + exports[`SassProcessor package exports resolution reports ambiguous extensionless exports: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -1797,6 +1951,28 @@ export default styles;", } `; +exports[`SassProcessor package exports resolution tracks manifests for package imports made by a dependency stylesheet: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor package exports resolution tracks manifests for package imports made by a dependency stylesheet: written-files 1`] = ` +Map { + "/fake/output/dts/exports.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/exports.module.css" => ".root { + color: #abcdef; +}", +} +`; + exports[`SassProcessor postProcessCssAsync passes compiled CSS through the post-processor callback: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/package.json b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/package.json index 99529ffdd4..3b589a8a62 100644 --- a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/package.json +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/package.json @@ -6,6 +6,8 @@ "sass": "./styles/_tokens.scss" }, "./theme/*.scss": "./styles/*.scss", - "./fallback/*.scss": ["./missing/*.scss", "./styles/*.scss"] + "./fallback/*.scss": ["./missing/*.scss", "./styles/*.scss"], + "./directory/*": "./styles/*", + "./theme.v1/_index.scss": "./styles/theme/_index.scss" } } diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/theme/_index.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/theme/_index.scss new file mode 100644 index 0000000000..b75f1b81b3 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/styles/theme/_index.scss @@ -0,0 +1 @@ +$brand: #789abc; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/src/_package-consumer.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/src/_package-consumer.scss new file mode 100644 index 0000000000..ce04f91397 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/src/_package-consumer.scss @@ -0,0 +1 @@ +@forward 'pkg:@test/exported-styles';