Skip to content

Commit 43ea510

Browse files
committed
[heft-sass-plugin] Emit declaration source maps for generated typings
Sass typings are merged into the source tree via rootDirs, so the language service only sees the generated .d.ts and go-to-definition on a CSS module class stops there instead of opening the rule that declares it. Add an opt-in generateDeclarationMaps option that emits a .d.ts.map beside each generated typings file. Positions are obtained by recording where each class selector appears in the compiled CSS, before postcss-modules rewrites names, and translating that position back through the Sass source map. A class declared in an imported partial therefore resolves into that partial, and a class restated inside a media query still resolves to its top-level rule. The shared pieces live in typings-generator: serializeDeclarationMap now accepts multiple sources, and decodeMappings/originalPositionFor are exported for generators that compile their input. The Sass-specific helpers are exported from heft-sass-plugin so that other Sass typings generators can reuse them rather than reimplement the same chain.
1 parent bc5d7dc commit 43ea510

15 files changed

Lines changed: 573 additions & 15 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/heft-sass-plugin",
5+
"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.",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/heft-sass-plugin"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/typings-generator",
5+
"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.",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/typings-generator"
10+
}

common/config/subspaces/default/pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/reviews/api/typings-generator.api.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
```ts
66

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

910
// @public
1011
export interface IDeclarationMapping {
1112
generatedColumn: number;
1213
generatedLine: number;
14+
sourceIndex?: number;
1315
sourcePosition: ISourcePosition;
1416
}
1517

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

109+
// @public
110+
export function originalPositionFor(decoded: readonly SourceMapSegment[][], line: number, column: number): {
111+
sourceIndex: number;
112+
line: number;
113+
column: number;
114+
} | undefined;
115+
107116
// @public (undocumented)
108117
export type ReadFile<TFileContents = string> = (filePath: string, relativePath: string) => Promise<TFileContents> | TFileContents;
109118

110119
// @public
111-
export function serializeDeclarationMap(mappings: readonly IDeclarationMapping[], generatedFileName: string, sourcePath: string, generatedLineOffset: number): string;
120+
export function serializeDeclarationMap(mappings: readonly IDeclarationMapping[], generatedFileName: string, sources: string | readonly string[], generatedLineOffset: number): string;
112121

113122
// @public
114123
export class StringValuesTypingsGenerator<TFileContents = string> extends TypingsGenerator<TFileContents> {

heft-plugins/heft-sass-plugin/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@
4646
"@rushstack/heft": "^1.2.22"
4747
},
4848
"dependencies": {
49+
"@jridgewell/sourcemap-codec": "~1.5.5",
4950
"@rushstack/node-core-library": "workspace:*",
51+
"@rushstack/typings-generator": "workspace:*",
5052
"@types/tapable": "1.0.6",
5153
"postcss": "~8.5.10",
5254
"postcss-modules": "~6.0.0",
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as path from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
7+
import type { Plugin as PostcssPlugin, Rule } from 'postcss';
8+
import { decode, type SourceMapSegment } from '@jridgewell/sourcemap-codec';
9+
10+
import { originalPositionFor, type ISourcePosition } from '@rushstack/typings-generator';
11+
12+
/**
13+
* The location of a class declaration in the original stylesheet. The class may be declared in an
14+
* imported partial rather than the entry file, so the file is tracked alongside the position.
15+
*
16+
* @public
17+
*/
18+
export interface IResolvedClassPosition extends ISourcePosition {
19+
/** Absolute path of the stylesheet that declares the class. */
20+
absoluteSourcePath: string;
21+
}
22+
23+
/**
24+
* The subset of a raw source map consumed when resolving positions.
25+
*
26+
* @public
27+
*/
28+
export interface IRawSourceMap {
29+
sources: string[];
30+
mappings: string;
31+
sourceRoot?: string;
32+
}
33+
34+
/**
35+
* Records where each class selector first appears in the CSS being processed.
36+
*
37+
* @public
38+
*/
39+
export interface IClassPositionRecorder {
40+
/** Must be registered before `postcss-modules`, which rewrites class names. */
41+
plugin: PostcssPlugin;
42+
positions: Map<string, ISourcePosition>;
43+
}
44+
45+
/**
46+
* Matches each class in a selector, capturing its name.
47+
*
48+
* Every class is captured, including each one in a compound selector such as `.primary.secondary`
49+
* and a class qualified by an element such as `div.only`, because CSS Modules exports all of them
50+
* and each therefore needs a mapping. The negative lookbehind skips an escaped dot so that a
51+
* literal `.` inside a name is not treated as the start of another class.
52+
*/
53+
const CLASS_SELECTOR_REGEXP: RegExp = /(?<!\\)\.([A-Za-z_-][A-Za-z0-9_-]*)/g;
54+
55+
/**
56+
* Creates a PostCSS plugin that records the position of each class selector in the CSS being
57+
* processed.
58+
*
59+
* Positions are recorded in compiled-CSS order, so the top-level rule for a class is kept rather
60+
* than a later restatement inside a media query or theme block.
61+
*
62+
* @public
63+
*/
64+
export function createClassPositionRecorder(): IClassPositionRecorder {
65+
const positions: Map<string, ISourcePosition> = new Map();
66+
67+
const plugin: PostcssPlugin = {
68+
postcssPlugin: 'rushstack-record-class-positions',
69+
Rule(rule: Rule): void {
70+
const start: { line: number; column: number } | undefined = rule.source?.start;
71+
if (!start) {
72+
return;
73+
}
74+
75+
// PostCSS positions are one-based.
76+
const position: ISourcePosition = { line: start.line - 1, column: start.column - 1 };
77+
78+
for (const selector of rule.selectors) {
79+
CLASS_SELECTOR_REGEXP.lastIndex = 0;
80+
let match: RegExpExecArray | null;
81+
while ((match = CLASS_SELECTOR_REGEXP.exec(selector)) !== null) {
82+
if (!positions.has(match[1])) {
83+
positions.set(match[1], position);
84+
}
85+
}
86+
}
87+
}
88+
};
89+
90+
return { plugin, positions };
91+
}
92+
93+
/**
94+
* Converts a `sources` entry from a Sass source map into an absolute file path. Sass emits `file:`
95+
* URLs by default, but a compilation driven through a custom importer may use another scheme, in
96+
* which case the caller supplies its own resolver.
97+
*
98+
* @public
99+
*/
100+
export function resolveSourceUrl(source: string, baseFolder: string): string {
101+
if (source.startsWith('file:')) {
102+
return fileURLToPath(source);
103+
}
104+
105+
return path.resolve(baseFolder, source);
106+
}
107+
108+
/**
109+
* Translates recorded compiled-CSS positions back to the original stylesheets, using the source map
110+
* that Sass produced for the compilation.
111+
*
112+
* Classes whose position cannot be mapped are omitted, leaving navigation for those names
113+
* unchanged.
114+
*
115+
* `resolveSourcePath` converts a `sources` entry from the Sass source map into an absolute file
116+
* path; it defaults to {@link resolveSourceUrl}.
117+
*
118+
* @public
119+
*/
120+
export function resolveStylesheetPositions(
121+
cssPositions: ReadonlyMap<string, ISourcePosition>,
122+
sassSourceMap: IRawSourceMap,
123+
baseFolder: string,
124+
resolveSourcePath: (source: string, baseFolder: string) => string = resolveSourceUrl
125+
): Map<string, IResolvedClassPosition> {
126+
const resolved: Map<string, IResolvedClassPosition> = new Map();
127+
const decoded: SourceMapSegment[][] = decode(sassSourceMap.mappings);
128+
const sourceRoot: string = sassSourceMap.sourceRoot ? sassSourceMap.sourceRoot.replace(/\/?$/, '/') : '';
129+
130+
const absoluteSources: (string | undefined)[] = sassSourceMap.sources.map((source: string) => {
131+
try {
132+
return resolveSourcePath(`${sourceRoot}${source}`, baseFolder);
133+
} catch {
134+
// An unrecognized source is skipped rather than failing the build.
135+
return undefined;
136+
}
137+
});
138+
139+
for (const [className, cssPosition] of cssPositions) {
140+
const original: { sourceIndex: number; line: number; column: number } | undefined = originalPositionFor(
141+
decoded,
142+
cssPosition.line,
143+
cssPosition.column
144+
);
145+
if (!original) {
146+
continue;
147+
}
148+
149+
const absoluteSourcePath: string | undefined = absoluteSources[original.sourceIndex];
150+
if (!absoluteSourcePath) {
151+
continue;
152+
}
153+
154+
resolved.set(className, {
155+
absoluteSourcePath,
156+
line: original.line,
157+
column: original.column
158+
});
159+
}
160+
161+
return resolved;
162+
}

heft-plugins/heft-sass-plugin/src/SassPlugin.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface ISassConfigurationJson {
3232
doNotTrimOriginalFileExtension?: boolean;
3333
preserveIcssExports?: boolean;
3434
sourceMap?: boolean;
35+
generateDeclarationMaps?: boolean;
3536
}
3637

3738
const SASS_CONFIGURATION_LOCATION: string = 'config/sass.json';
@@ -102,7 +103,8 @@ export default class SassPlugin implements IHeftPlugin {
102103
excludeFiles,
103104
doNotTrimOriginalFileExtension,
104105
preserveIcssExports,
105-
sourceMap
106+
sourceMap,
107+
generateDeclarationMaps
106108
} = sassConfigurationJson || {};
107109

108110
function resolveFolder(folder: string): string {
@@ -132,6 +134,7 @@ export default class SassPlugin implements IHeftPlugin {
132134
doNotTrimOriginalFileExtension,
133135
preserveIcssExports,
134136
sourceMap,
137+
generateDeclarationMaps,
135138
postProcessCssAsync: hooks.postProcessCss.isUsed()
136139
? async (cssText: string) => hooks.postProcessCss.promise(cssText)
137140
: undefined

0 commit comments

Comments
 (0)