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": "Add an opt-in \"generateDeclarationMaps\" option that emits a \".d.ts.map\" beside each generated typings file, so that \"go to definition\" on a CSS module class resolves to the rule in the stylesheet instead of the generated typings.",
"type": "minor"
}
],
"packageName": "@rushstack/heft-sass-plugin"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/typings-generator",
"comment": "Support multiple sources in \"serializeDeclarationMap\", and add \"originalPositionFor\" so that generators which compile their input can translate a position in the compiled output back to the original file.",
"type": "minor"
}
],
"packageName": "@rushstack/typings-generator"
}
6 changes: 6 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.

11 changes: 10 additions & 1 deletion common/reviews/api/typings-generator.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
```ts

import { ITerminal } from '@rushstack/terminal';
import { SourceMapSegment } from '@jridgewell/sourcemap-codec';

// @public
export interface IDeclarationMapping {
generatedColumn: number;
generatedLine: number;
sourceIndex?: number;
sourcePosition: ISourcePosition;
}

Expand Down Expand Up @@ -104,11 +106,18 @@ export interface ITypingsGeneratorOptionsWithoutReadFile<TTypingsResult = string
parseAndGenerateTypings: (fileContents: TFileContents, filePath: string, relativePath: string) => TTypingsResult | Promise<TTypingsResult>;
}

// @public
export function originalPositionFor(decoded: readonly SourceMapSegment[][], line: number, column: number): {
sourceIndex: number;
line: number;
column: number;
} | undefined;

// @public (undocumented)
export type ReadFile<TFileContents = string> = (filePath: string, relativePath: string) => Promise<TFileContents> | TFileContents;

// @public
export function serializeDeclarationMap(mappings: readonly IDeclarationMapping[], generatedFileName: string, sourcePath: string, generatedLineOffset: number): string;
export function serializeDeclarationMap(mappings: readonly IDeclarationMapping[], generatedFileName: string, sources: string | readonly string[], generatedLineOffset: number): string;

// @public
export class StringValuesTypingsGenerator<TFileContents = string> extends TypingsGenerator<TFileContents> {
Expand Down
2 changes: 2 additions & 0 deletions heft-plugins/heft-sass-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@
"@rushstack/heft": "^1.2.22"
},
"dependencies": {
"@jridgewell/sourcemap-codec": "~1.5.5",
"@rushstack/node-core-library": "workspace:*",
"@rushstack/typings-generator": "workspace:*",
"@types/tapable": "1.0.6",
"postcss": "~8.5.10",
"postcss-modules": "~6.0.0",
Expand Down
162 changes: 162 additions & 0 deletions heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// 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 type { Plugin as PostcssPlugin, Rule } from 'postcss';
import { decode, type SourceMapSegment } from '@jridgewell/sourcemap-codec';

import { originalPositionFor, type ISourcePosition } from '@rushstack/typings-generator';

/**
* The location of a class declaration in the original stylesheet. The class may be declared in an
* imported partial rather than the entry file, so the file is tracked alongside the position.
*
* @public
*/
export interface IResolvedClassPosition extends ISourcePosition {
/** Absolute path of the stylesheet that declares the class. */
absoluteSourcePath: string;
}

/**
* The subset of a raw source map consumed when resolving positions.
*
* @public
*/
export interface IRawSourceMap {
sources: string[];
mappings: string;
sourceRoot?: string;
}

/**
* Records where each class selector first appears in the CSS being processed.
*
* @public
*/
export interface IClassPositionRecorder {
/** Must be registered before `postcss-modules`, which rewrites class names. */
plugin: PostcssPlugin;
positions: Map<string, ISourcePosition>;
}

/**
* Matches each class in a selector, capturing its name.
*
* Every class is captured, including each one in a compound selector such as `.primary.secondary`
* and a class qualified by an element such as `div.only`, because CSS Modules exports all of them
* and each therefore needs a mapping. The negative lookbehind skips an escaped dot so that a
* literal `.` inside a name is not treated as the start of another class.
*/
const CLASS_SELECTOR_REGEXP: RegExp = /(?<!\\)\.([A-Za-z_-][A-Za-z0-9_-]*)/g;

/**
* Creates a PostCSS plugin that records the position of each class selector in the CSS being
* processed.
*
* Positions are recorded in compiled-CSS order, so the top-level rule for a class is kept rather
* than a later restatement inside a media query or theme block.
*
* @public
*/
export function createClassPositionRecorder(): IClassPositionRecorder {
const positions: Map<string, ISourcePosition> = new Map();

const plugin: PostcssPlugin = {
postcssPlugin: 'rushstack-record-class-positions',
Rule(rule: Rule): void {
const start: { line: number; column: number } | undefined = rule.source?.start;
if (!start) {
return;
}

// PostCSS positions are one-based.
const position: ISourcePosition = { line: start.line - 1, column: start.column - 1 };

for (const selector of rule.selectors) {
CLASS_SELECTOR_REGEXP.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CLASS_SELECTOR_REGEXP.exec(selector)) !== null) {
if (!positions.has(match[1])) {
positions.set(match[1], position);
}
}
}
}
};

return { plugin, positions };
}

/**
* Converts a `sources` entry from a Sass source map into an absolute file path. Sass emits `file:`
* URLs by default, but a compilation driven through a custom importer may use another scheme, in
* which case the caller supplies its own resolver.
*
* @public
*/
export function resolveSourceUrl(source: string, baseFolder: string): string {
if (source.startsWith('file:')) {
return fileURLToPath(source);
}

return path.resolve(baseFolder, source);
}

/**
* Translates recorded compiled-CSS positions back to the original stylesheets, using the source map
* that Sass produced for the compilation.
*
* Classes whose position cannot be mapped are omitted, leaving navigation for those names
* unchanged.
*
* `resolveSourcePath` converts a `sources` entry from the Sass source map into an absolute file
* path; it defaults to {@link resolveSourceUrl}.
*
* @public
*/
export function resolveStylesheetPositions(
cssPositions: ReadonlyMap<string, ISourcePosition>,
sassSourceMap: IRawSourceMap,
baseFolder: string,
resolveSourcePath: (source: string, baseFolder: string) => string = resolveSourceUrl
): Map<string, IResolvedClassPosition> {
const resolved: Map<string, IResolvedClassPosition> = new Map();
const decoded: SourceMapSegment[][] = decode(sassSourceMap.mappings);
const sourceRoot: string = sassSourceMap.sourceRoot ? sassSourceMap.sourceRoot.replace(/\/?$/, '/') : '';

const absoluteSources: (string | undefined)[] = sassSourceMap.sources.map((source: string) => {
try {
return resolveSourcePath(`${sourceRoot}${source}`, baseFolder);
} catch {
// An unrecognized source is skipped rather than failing the build.
return undefined;
}
});

for (const [className, cssPosition] of cssPositions) {
const original: { sourceIndex: number; line: number; column: number } | undefined = originalPositionFor(
decoded,
cssPosition.line,
cssPosition.column
);
if (!original) {
continue;
}

const absoluteSourcePath: string | undefined = absoluteSources[original.sourceIndex];
if (!absoluteSourcePath) {
continue;
}

resolved.set(className, {
absoluteSourcePath,
line: original.line,
column: original.column
});
}

return resolved;
}
5 changes: 4 additions & 1 deletion heft-plugins/heft-sass-plugin/src/SassPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface ISassConfigurationJson {
doNotTrimOriginalFileExtension?: boolean;
preserveIcssExports?: boolean;
sourceMap?: boolean;
generateDeclarationMaps?: boolean;
}

const SASS_CONFIGURATION_LOCATION: string = 'config/sass.json';
Expand Down Expand Up @@ -102,7 +103,8 @@ export default class SassPlugin implements IHeftPlugin {
excludeFiles,
doNotTrimOriginalFileExtension,
preserveIcssExports,
sourceMap
sourceMap,
generateDeclarationMaps
} = sassConfigurationJson || {};

function resolveFolder(folder: string): string {
Expand Down Expand Up @@ -132,6 +134,7 @@ export default class SassPlugin implements IHeftPlugin {
doNotTrimOriginalFileExtension,
preserveIcssExports,
sourceMap,
generateDeclarationMaps,
postProcessCssAsync: hooks.postProcessCss.isUsed()
? async (cssText: string) => hooks.postProcessCss.promise(cssText)
: undefined
Expand Down
Loading