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/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/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..fe276a33d0 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -229,6 +229,45 @@ 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. +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. +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,13 +295,17 @@ 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 -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/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/SassPackageResolver.ts b/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts new file mode 100644 index 0000000000..380aa40204 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts @@ -0,0 +1,124 @@ +// 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, 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; +} + +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) || FileSystem.isDirectoryError(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/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 7b062c392c..e2e8d9057e 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -28,12 +28,19 @@ import { FileSystem, type IFileSystemWriteFileOptions, Import, + JsonFile, type JsonObject, Path, RealNodeModulePathResolver, Sort } from '@rushstack/node-core-library'; +import { + resolveSassPackageExportsAsync, + type ISassPackageExport, + type ISassPackageJson +} from './SassPackageResolver'; + const SIMPLE_IDENTIFIER_REGEX: RegExp = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; /** @@ -177,6 +184,7 @@ interface IFileRecord { index: number; isPartial: boolean; isModule: boolean; + isResolutionDependency: boolean; relativePath: string; version: string; content: string | undefined; @@ -190,6 +198,7 @@ interface ISerializedFileRecord { version: string; cssVersion?: string | undefined; dependencies: number[]; + isResolutionDependency?: boolean; } /** @@ -230,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[]; @@ -263,9 +273,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; } @@ -291,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 = { @@ -307,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`); @@ -325,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' @@ -378,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); } } @@ -502,7 +524,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 +550,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,10 +578,75 @@ export class SassProcessor { baseFolderPath, getRealPath: this.#realpathSync }); - const modulePath: string = nodeModulesQuery.slice(linkEnd); - const resolvedPath: string = `${resolvedPackagePath}${modulePath}`; - const heftUrl: string = pathToHeftUrl(resolvedPath).href; - return await this.#canonicalizeHeftUrlAsync(heftUrl, context); + 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 = JsonFile.parseString(manifestRecord.content); + + let resolution: SyncResolution = null; + if (packageExports) { + const exportedFile: ISassPackageExport | undefined = await resolveSassPackageExportsAsync( + resolvedPackagePath, + modulePath, + packageExports + ); + 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; + record.version = version; + } + + // Exports specify complete filenames, without implicit partials or extensions. + resolution = pathToHeftUrl(filePath); + } + } + + if (!resolution && !modulePath) { + for (const field of [sass, style]) { + if (typeof field === 'string') { + resolution = await this.#canonicalizeHeftUrlAsync( + pathToHeftUrl(path.resolve(resolvedPackagePath, field)).href, + context + ); + if (resolution) { + break; + } + } + } + } + + 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; } /** @@ -647,10 +734,17 @@ 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; + } else { + throw error; + } } } @@ -691,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 }; }); @@ -709,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, @@ -755,18 +852,8 @@ 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; - } - }; + const isStylesheet: boolean = !this.#fileInfo.get(absolutePath)?.isResolutionDependency; + return getFileContentAndVersion(content, isStylesheet); } /** @@ -774,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: '', @@ -831,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(); @@ -1146,6 +1242,21 @@ function isBareSpecifier(url: string): boolean { return url.length > 0 && !url.startsWith('.') && !url.startsWith('/') && !urlSchemeRegex.test(url); } +function getFileContentAndVersion(content: Buffer, isStylesheet: boolean = true): 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 ??= isStylesheet ? preprocessScss(content) : content.toString('utf8'); + return contentString; + } + }; +} + 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/SassPackageResolver.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts new file mode 100644 index 0000000000..c19b690604 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts @@ -0,0 +1,298 @@ +// 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: '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' }, + 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 + ); + 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) => path.relative(result.filePath, fileURLToPath(url)))).toContain( + '' + ); + 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 dfef765220..b5eefedfad 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 }); } @@ -901,6 +902,346 @@ 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`; + + async function writePackageAsync(fields: Record): Promise { + await writeFixtureFileAsync( + `${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 }> { + 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 } + ); + await result.processor.compileFilesAsync(new Set([sourcePath])); + return result; + } + + beforeEach(async () => { + await FileSystem.ensureEmptyFolderAsync(testFolder); + await FileSystem.copyFilesAsync({ + sourcePath: `${fixturesFolder}/package-exports`, + destinationPath: testFolder + }); + }); + + afterAll(async () => { + await FileSystem.deleteFolderAsync(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) => { + await writePackageAsync({ 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 () => { + await writePackageAsync({ + 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) => { + 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') { + await writePackageAsync({ 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 () => { + await writePackageAsync({ + 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('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) => { + await writePackageAsync({ 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) => { + await writePackageAsync({ + 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 () => { + await writePackageAsync({ 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 () => { + await writePackageAsync({ 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 () => { + await writePackageAsync({ 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 () => { + await writePackageAsync({ 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 () => { + await writePackageAsync({ 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 () => { + await writePackageAsync({ exports: { '.': { sass: './styles/_tokens.scss' } } }); + const paletteContent: string = '$brand: #123456;\n.palette { color: $brand; }\n'; + 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') + ); + + 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 () => { + 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 } + ); + 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 () => { + 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`; + await writeFixtureFileAsync(palettePath, '$brand: #654321;\n'); + await processor.compileFilesAsync(new Set([palettePath])); + + expect(logger.errors).toHaveLength(0); + 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`, { + 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 () => { + await writePackageAsync({ 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 () => { + await writePackageAsync({ + 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 () => { + await writePackageAsync({ 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..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 @@ -1142,6 +1142,837 @@ 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 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]", + "[ 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 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]", + "[ 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 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]", + "[ 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 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/.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..3b589a8a62 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/package-exports/node_modules/@test/exported-styles/package.json @@ -0,0 +1,13 @@ +{ + "name": "@test/exported-styles", + "version": "1.0.0", + "exports": { + ".": { + "sass": "./styles/_tokens.scss" + }, + "./theme/*.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/_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/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'; 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; +}