Skip to content

Commit 282cc9b

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 fc05315 commit 282cc9b

13 files changed

Lines changed: 524 additions & 14 deletions

File tree

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 \"decodeMappings\" and \"originalPositionFor\" so that generators which compile their input can translate positions 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: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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 a class selector, capturing its name. The leading boundary avoids matching `foo` in a
47+
* compound selector such as `.a.foo`, where it is not the subject of the rule.
48+
*/
49+
const CLASS_SELECTOR_REGEXP: RegExp = /(?:^|[\s>+~])\.([A-Za-z_-][A-Za-z0-9_-]*)/g;
50+
51+
/**
52+
* Creates a PostCSS plugin that records the position of each class selector in the CSS being
53+
* processed.
54+
*
55+
* Positions are recorded in compiled-CSS order, so the top-level rule for a class is kept rather
56+
* than a later restatement inside a media query or theme block.
57+
*
58+
* @public
59+
*/
60+
export function createClassPositionRecorder(): IClassPositionRecorder {
61+
const positions: Map<string, ISourcePosition> = new Map();
62+
63+
const plugin: PostcssPlugin = {
64+
postcssPlugin: 'rushstack-record-class-positions',
65+
Rule(rule: Rule): void {
66+
const start: { line: number; column: number } | undefined = rule.source?.start;
67+
if (!start) {
68+
return;
69+
}
70+
71+
// PostCSS positions are one-based.
72+
const position: ISourcePosition = { line: start.line - 1, column: start.column - 1 };
73+
74+
for (const selector of rule.selectors) {
75+
CLASS_SELECTOR_REGEXP.lastIndex = 0;
76+
let match: RegExpExecArray | null;
77+
while ((match = CLASS_SELECTOR_REGEXP.exec(selector)) !== null) {
78+
if (!positions.has(match[1])) {
79+
positions.set(match[1], position);
80+
}
81+
}
82+
}
83+
}
84+
};
85+
86+
return { plugin, positions };
87+
}
88+
89+
/**
90+
* Converts a `sources` entry from a Sass source map into an absolute file path. Sass emits `file:`
91+
* URLs by default, but a compilation driven through a custom importer may use another scheme, in
92+
* which case the caller supplies its own resolver.
93+
*
94+
* @public
95+
*/
96+
export function resolveSourceUrl(source: string, baseFolder: string): string {
97+
if (source.startsWith('file:')) {
98+
return fileURLToPath(source);
99+
}
100+
101+
return path.resolve(baseFolder, source);
102+
}
103+
104+
/**
105+
* Translates recorded compiled-CSS positions back to the original stylesheets, using the source map
106+
* that Sass produced for the compilation.
107+
*
108+
* Classes whose position cannot be mapped are omitted, leaving navigation for those names
109+
* unchanged.
110+
*
111+
* `resolveSourcePath` converts a `sources` entry from the Sass source map into an absolute file
112+
* path; it defaults to {@link resolveSourceUrl}.
113+
*
114+
* @public
115+
*/
116+
export function resolveStylesheetPositions(
117+
cssPositions: ReadonlyMap<string, ISourcePosition>,
118+
sassSourceMap: IRawSourceMap,
119+
baseFolder: string,
120+
resolveSourcePath: (source: string, baseFolder: string) => string = resolveSourceUrl
121+
): Map<string, IResolvedClassPosition> {
122+
const resolved: Map<string, IResolvedClassPosition> = new Map();
123+
const decoded: SourceMapSegment[][] = decode(sassSourceMap.mappings);
124+
const sourceRoot: string = sassSourceMap.sourceRoot ? sassSourceMap.sourceRoot.replace(/\/?$/, '/') : '';
125+
126+
const absoluteSources: (string | undefined)[] = sassSourceMap.sources.map((source: string) => {
127+
try {
128+
return resolveSourcePath(`${sourceRoot}${source}`, baseFolder);
129+
} catch {
130+
// An unrecognized source is skipped rather than failing the build.
131+
return undefined;
132+
}
133+
});
134+
135+
for (const [className, cssPosition] of cssPositions) {
136+
const original: { sourceIndex: number; line: number; column: number } | undefined = originalPositionFor(
137+
decoded,
138+
cssPosition.line,
139+
cssPosition.column
140+
);
141+
if (!original) {
142+
continue;
143+
}
144+
145+
const absoluteSourcePath: string | undefined = absoluteSources[original.sourceIndex];
146+
if (!absoluteSourcePath) {
147+
continue;
148+
}
149+
150+
resolved.set(className, {
151+
absoluteSourcePath,
152+
line: original.line,
153+
column: original.column
154+
});
155+
}
156+
157+
return resolved;
158+
}

0 commit comments

Comments
 (0)