Skip to content

Commit 57956fd

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 ce4dd1d commit 57956fd

16 files changed

Lines changed: 695 additions & 25 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: 8 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
22
{
3-
"pnpmShrinkwrapHash": "288ee1cccceca305122bc3d4b1eb550efbcb2a0b",
3+
"pnpmShrinkwrapHash": "121b6455d44723fc7bdb38b7d038aae935a28bd1",
44
"preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c"
55
}

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,29 @@
66

77
import { ITerminal } from '@rushstack/terminal';
88

9+
// @public
10+
export function decodeMappings(mappings: string): IDecodedSegment[][];
11+
912
// @public
1013
export interface IDeclarationMapping {
1114
generatedColumn: number;
1215
generatedLine: number;
16+
sourceIndex?: number;
1317
sourcePosition: ISourcePosition;
1418
}
1519

20+
// @public
21+
export interface IDecodedSegment {
22+
// (undocumented)
23+
generatedColumn: number;
24+
// (undocumented)
25+
sourceColumn?: number;
26+
// (undocumented)
27+
sourceIndex?: number;
28+
// (undocumented)
29+
sourceLine?: number;
30+
}
31+
1632
// @public (undocumented)
1733
export interface IExportAsDefaultOptions {
1834
// @deprecated (undocumented)
@@ -104,11 +120,18 @@ export interface ITypingsGeneratorOptionsWithoutReadFile<TTypingsResult = string
104120
parseAndGenerateTypings: (fileContents: TFileContents, filePath: string, relativePath: string) => TTypingsResult | Promise<TTypingsResult>;
105121
}
106122

123+
// @public
124+
export function originalPositionFor(decoded: readonly IDecodedSegment[][], line: number, column: number): {
125+
sourceIndex: number;
126+
line: number;
127+
column: number;
128+
} | undefined;
129+
107130
// @public (undocumented)
108131
export type ReadFile<TFileContents = string> = (filePath: string, relativePath: string) => Promise<TFileContents> | TFileContents;
109132

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

113136
// @public
114137
export class StringValuesTypingsGenerator<TFileContents = string> extends TypingsGenerator<TFileContents> {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
},
4848
"dependencies": {
4949
"@rushstack/node-core-library": "workspace:*",
50+
"@rushstack/typings-generator": "workspace:*",
5051
"@types/tapable": "1.0.6",
5152
"postcss": "~8.5.10",
5253
"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+
9+
import {
10+
decodeMappings,
11+
originalPositionFor,
12+
type IDecodedSegment,
13+
type ISourcePosition
14+
} from '@rushstack/typings-generator';
15+
16+
/**
17+
* The location of a class declaration in the original stylesheet. The class may be declared in an
18+
* imported partial rather than the entry file, so the file is tracked alongside the position.
19+
*
20+
* @public
21+
*/
22+
export interface IResolvedClassPosition extends ISourcePosition {
23+
/** Absolute path of the stylesheet that declares the class. */
24+
absoluteSourcePath: string;
25+
}
26+
27+
/**
28+
* The subset of a raw source map consumed when resolving positions.
29+
*
30+
* @public
31+
*/
32+
export interface IRawSourceMap {
33+
sources: string[];
34+
mappings: string;
35+
sourceRoot?: string;
36+
}
37+
38+
/**
39+
* Records where each class selector first appears in the CSS being processed.
40+
*
41+
* @public
42+
*/
43+
export interface IClassPositionRecorder {
44+
/** Must be registered before `postcss-modules`, which rewrites class names. */
45+
plugin: PostcssPlugin;
46+
positions: Map<string, ISourcePosition>;
47+
}
48+
49+
/**
50+
* Matches a class selector, capturing its name. The leading boundary avoids matching `foo` in a
51+
* compound selector such as `.a.foo`, where it is not the subject of the rule.
52+
*/
53+
const CLASS_SELECTOR_REGEXP: RegExp = /(?:^|[\s>+~])\.([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: IDecodedSegment[][] = decodeMappings(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+
}

0 commit comments

Comments
 (0)