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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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"
}
4 changes: 4 additions & 0 deletions common/config/rush/nonbrowser-approved-packages.json
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,10 @@
"name": "resolve",
"allowedCategories": [ "libraries" ]
},
{
"name": "resolve-pkg-maps",
"allowedCategories": [ "libraries" ]
},
{
"name": "run-scenarios-helpers",
"allowedCategories": [ "tests" ]
Expand Down
8 changes: 8 additions & 0 deletions common/config/subspaces/default/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion common/config/subspaces/default/repo-state.json
Original file line number Diff line number Diff line change
@@ -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"
}
49 changes: 46 additions & 3 deletions heft-plugins/heft-sass-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion heft-plugins/heft-sass-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
124 changes: 124 additions & 0 deletions heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts
Original file line number Diff line number Diff line change
@@ -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<string> = new Set(['.scss', '.sass', '.css']);

// IPackageJson does not currently cover arbitrary export conditions or nested fallback arrays.
export interface ISassPackageJson extends Omit<IPackageJson, 'exports'> {
exports?: PathConditions;
sass?: string;
style?: string;
}

export interface ISassPackageExport {
filePath: string;
contents?: Buffer;
}

export async function resolveSassPackageExportsAsync(
packageFolder: string,
subpath: string,
packageExports: PathConditions
): Promise<ISassPackageExport | undefined> {
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<string, ISassPackageExport> = 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;
}
14 changes: 14 additions & 0 deletions heft-plugins/heft-sass-plugin/src/SassPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,21 @@ export default class SassPlugin implements IHeftPlugin {
}
}

const watchedManifests: Set<string> = 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);
}
}
}
);
}
Expand Down
Loading
Loading