[heft-sass-plugin] Emit declaration source maps for generated typings - #5909
Open
Mike DelGaudio (mikedelgaudio) wants to merge 2 commits into
Open
Conversation
Mike DelGaudio (mikedelgaudio)
force-pushed
the
feature/heft-sass-plugin-declaration-maps
branch
2 times, most recently
from
August 3, 2026 17:56
028b86c to
e7ddee8
Compare
Mike DelGaudio (mikedelgaudio)
force-pushed
the
feature/heft-sass-plugin-declaration-maps
branch
3 times, most recently
from
August 3, 2026 18:25
282cc9b to
9a7ad7f
Compare
Contributor
|
The dependency is merged now, so please rebase |
There was a problem hiding this comment.
Pull request overview
Adds opt-in declaration source maps for generated Sass typings, enabling editor navigation back to originating stylesheet rules.
Changes:
- Adds shared declaration-map encoding and lookup utilities.
- Maps Sass class declarations through PostCSS and Sass source maps.
- Adds configuration, dependencies, tests, API reports, and change files.
Show a summary per file
| File | Description |
|---|---|
libraries/typings-generator/src/TypingsGenerator.ts |
Emits declaration maps for generated typings. |
libraries/typings-generator/src/test/DeclarationMap.test.ts |
Tests declaration-map generation. |
libraries/typings-generator/src/StringValuesTypingsGenerator.ts |
Records generated declaration positions. |
libraries/typings-generator/src/index.ts |
Exports declaration-map APIs. |
libraries/typings-generator/src/DeclarationMap.ts |
Implements source-map serialization and lookup. |
libraries/typings-generator/package.json |
Adds source-map codec dependency. |
libraries/localization-utilities/src/TypingsGenerator.ts |
Passes localization source positions onward. |
libraries/localization-utilities/src/parsers/test/__snapshots__/parseResx.test.ts.snap |
Updates parser snapshots. |
libraries/localization-utilities/src/parsers/parseResx.ts |
Records RESX declaration positions. |
libraries/localization-utilities/src/interfaces.ts |
Exposes localization source positions. |
heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts |
Tests Sass declaration maps. |
heft-plugins/heft-sass-plugin/src/test/fixtures/partial-class.module.scss |
Adds entry-file mapping fixture. |
heft-plugins/heft-sass-plugin/src/test/fixtures/_partial-with-class.scss |
Adds partial mapping fixture. |
heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap |
Captures declaration-map outputs. |
heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json |
Defines the new Sass option. |
heft-plugins/heft-sass-plugin/src/SassProcessor.ts |
Generates Sass declaration maps. |
heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts |
Records and resolves class positions. |
heft-plugins/heft-sass-plugin/src/index.ts |
Exports Sass mapping helpers. |
heft-plugins/heft-sass-plugin/package.json |
Adds mapping dependencies. |
heft-plugins/heft-localization-typings-plugin/src/schemas/heft-localization-typings-plugin.schema.json |
Defines localization map configuration. |
heft-plugins/heft-localization-typings-plugin/src/LocalizationTypingsPlugin.ts |
Exposes localization map options. |
common/reviews/api/typings-generator.api.md |
Updates typings-generator API report. |
common/reviews/api/localization-utilities.api.md |
Updates localization API report. |
common/config/subspaces/default/repo-state.json |
Updates Rush repository state. |
common/config/subspaces/default/pnpm-lock.yaml |
Updates dependency lock data. |
common/config/rush/browser-approved-packages.json |
Approves the codec dependency. |
common/changes/@rushstack/typings-generator/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json |
Records map-generation feature. |
common/changes/@rushstack/typings-generator/feature-declaration-map-decoding_2026-07-29-20-30-00.json |
Records multi-source lookup changes. |
common/changes/@rushstack/localization-utilities/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json |
Records localization changes. |
common/changes/@rushstack/heft-sass-plugin/feature-heft-sass-plugin-declaration-maps_2026-07-29-20-30-00.json |
Records Sass feature. |
common/changes/@rushstack/heft-localization-typings-plugin/feature-typings-generator-declaration-maps_2026-07-28-20-47-38.json |
Records localization plugin option. |
Review details
Files not reviewed (1)
- common/config/subspaces/default/pnpm-lock.yaml: Generated file
Suppressed comments (1)
heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts:72
- A single rule-start position is assigned to every class in the rule. For a multiline selector such as
.first,\n.second, both names are therefore translated through the Sass map at.first, and navigation forsecondlands on the wrong declaration. Record each matched selector node's own line/column offset (for example via a selector AST) instead of reusingrule.source.start.
// PostCSS positions are one-based.
const position: ISourcePosition = { line: start.line - 1, column: start.column - 1 };
- Files reviewed: 30/31 changed files
- Comments generated: 5
- Review effort level: Balanced
Comment on lines
+126
to
+128
| "generateDeclarationMaps": { | ||
| "type": "boolean", | ||
| "description": "If true, a `.d.ts.map` file is emitted next to each generated typings file, allowing editors to resolve \"go to definition\" on a CSS module class to the rule that declares it in the stylesheet instead of the generated typings. Defaults to `false`." |
Comment on lines
+867
to
+878
| const declarationMappings: IDeclarationMapping[] = []; | ||
| const declarationMapSources: string[] = []; | ||
| if (classPositions && declarationPositions && result.sourceMap) { | ||
| const sourcePositions: Map<string, IResolvedClassPosition> = resolveStylesheetPositions( | ||
| classPositions, | ||
| result.sourceMap, | ||
| path.dirname(sourceFilePath), | ||
| (source: string, baseFolder: string) => | ||
| source.startsWith('heft:') ? heftUrlToPath(source) : resolveSourceUrl(source, baseFolder) | ||
| ); | ||
|
|
||
| const sourceIndexByPath: Map<string, number> = new Map(); |
Comment on lines
+409
to
+411
| await FileSystem.writeFileAsync(`${generatedTsFilePath}.map`, serializedMap, { | ||
| ensureFolderExists: true | ||
| }); |
| * Matches a class selector, capturing its name. The leading boundary avoids matching `foo` in a | ||
| * compound selector such as `.a.foo`, where it is not the subject of the rule. | ||
| */ | ||
| const CLASS_SELECTOR_REGEXP: RegExp = /(?:^|[\s>+~])\.([A-Za-z_-][A-Za-z0-9_-]*)/g; |
| "changes": [ | ||
| { | ||
| "packageName": "@rushstack/typings-generator", | ||
| "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.", |
Generated typings such as .resx and .scss declarations are merged into the source tree via "rootDirs", so the TypeScript language service only ever sees the generated .d.ts. Alt-clicking a localized string therefore navigates to the generated declaration rather than the file that declares it. Add opt-in declaration source map generation to TypingsGenerator. The generator already composes its output line by line, so it knows the exact position of every emitted declaration and does not need to parse its own output. The map is serialized per output folder so that the relative path back to the source is correct for secondary folders as well. StringValuesTypingsGenerator records those positions from the new optional IStringValueTyping.sourcePosition, parseResx populates it from the xmldoc element, and heft-localization-typings-plugin exposes a generateDeclarationMaps option. Parsers that do not supply positions are unaffected, and no map is emitted unless the feature is enabled and positions are available.
Mike DelGaudio (mikedelgaudio)
force-pushed
the
feature/heft-sass-plugin-declaration-maps
branch
from
August 5, 2026 20:16
9a7ad7f to
43ea510
Compare
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.
Mike DelGaudio (mikedelgaudio)
force-pushed
the
feature/heft-sass-plugin-declaration-maps
branch
from
August 5, 2026 20:21
43ea510 to
effc406
Compare
There was a problem hiding this comment.
Review details
Files not reviewed (1)
- common/config/subspaces/default/pnpm-lock.yaml: Generated file
Suppressed comments (5)
libraries/typings-generator/src/DeclarationMap.ts:157
- A one-field source-map segment explicitly marks the following generated range as unmapped, but this loop leaves the previous mapped segment in
best. A lookup after such a boundary therefore returns an unrelated earlier source position. Clearbestwhen the latest preceding segment is unmapped, and only use the forward fallback when no segment precedes the requested column.
if (isMappedSegment(segment)) {
best = segment;
}
heft-plugins/heft-sass-plugin/src/SassProcessor.ts:869
- This map depends on
result.sourceMap, but the earlier incremental short-circuit hashes only the compiled CSS (SassProcessor.ts:797-800). Adding blank lines/comments, or making an equivalent edit in a partial, can move source positions without changing CSS; watch/incremental builds then return before this block and retain stale declaration mappings. Include the Sass map mappings in the output hash when declaration maps are enabled (or otherwise force map regeneration).
if (classPositions && declarationPositions && result.sourceMap) {
heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts:76
- Every class in a rule is assigned the rule's start position. For a multiline selector such as
.first,\n.second, both classes are therefore looked up at.first's compiled-CSS position, sosecondmaps to the wrong source rule. Record each matched class's actual line/column withinrule.selectorbefore resolving through the Sass map.
// PostCSS positions are one-based.
const position: ISourcePosition = { line: start.line - 1, column: start.column - 1 };
heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json:128
- The README's configuration reference says it lists all
config/sass.jsonoptions, but this new public option is absent. AddgenerateDeclarationMapsto that table so users can discover and configure the feature without inspecting the JSON schema.
"generateDeclarationMaps": {
"type": "boolean",
"description": "If true, a `.d.ts.map` file is emitted next to each generated typings file, allowing editors to resolve \"go to definition\" on a CSS module class to the rule that declares it in the stylesheet instead of the generated typings. Defaults to `false`."
heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts:53
- A regex over selector text does not identify exported CSS class nodes reliably. For example,
[data-value=".root"]or:global(.root)can record a false firstrootand misdirect a later local.root, while a valid escaped class such as.foo\:baris recorded asfooand receives no mapping under its exported name. Parse selectors with a CSS selector parser while honoring CSS Modules local/global semantics.
const CLASS_SELECTOR_REGEXP: RegExp = /(?<!\\)\.([A-Za-z_-][A-Za-z0-9_-]*)/g;
- Files reviewed: 16/17 changed files
- Comments generated: 0 new
- Review effort level: Balanced
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #5908
Sass typings are written into a folder that
rootDirsmerges into the source tree, so the languageservice only ever sees the generated
.d.ts. "Go to definition" on a CSS module class thereforestops at the generated declaration instead of opening the rule that declares it.
This adds an opt-in
generateDeclarationMapsoption that emits a.d.ts.mapbeside each generatedtypings file. It is off by default, and when disabled the output is unchanged.
This is the Sass counterpart to #5906 / #5907, which covered localization typings. Now that #5907
has merged, this branch has been rebased onto
mainand is standalone — it no longer carriesthat commit.
Details
The interesting part is obtaining accurate positions. Sass is compiled before PostCSS runs, so
PostCSS positions refer to the compiled CSS rather than the stylesheet. The chain is:
before
postcss-modules(which rewrites class names).createDTSreports the line it emits each declaration on, so nothing parses its own output.Because the lookup happens in compiled-CSS order, this gets two cases right that a text search over
the stylesheet cannot:
@imported partial resolves into that partial. It does not appear inthe importing file at all.
@mediaor theme block still resolves to its primary rule, with noindentation heuristics.
Shared vs. Sass-specific code
To avoid a second copy of this logic:
@rushstack/typings-generatorserializeDeclarationMapnow accepts multiplesources, andIDeclarationMappinggains an optionalsourceIndex— needed because Sass declarations can originate from several files. AddsoriginalPositionFor, a small lookup helper over mappings decoded by@jridgewell/sourcemap-codec(the codec itself is deliberately just encode/decode).@rushstack/heft-sass-pluginSassDeclarationMaps.tswith the PostCSS recorder and the resolve-through-Sass-map step, exported from the package index so other Sass typings generators can reuse it.Both packages use
@jridgewell/sourcemap-codecper the guidance on #5907; no hand-rolled VLQencoding or decoding is introduced.
Notes for reviewers:
serializeDeclarationMap'ssourcesparameter widens fromstringtostring | readonly string[], andsourceIndexdefaults to0, so existing callers areunaffected. The
heft-localization-typings-pluginpath is unchanged.sourceMapIncludeSourcesis not forced on. Enabling declaration maps requests a Sass sourcemap, but only embeds sources when
sourceMap: truewas already set, to avoid inflating output.typings-generatorhalf into its own PR if you would prefer to review theshared primitives separately.
Automated review feedback
Three real defects were flagged by the Copilot reviewer and are fixed here:
generateDeclarationMaps, butISassConfigurationJsonomitted it, soconfig/sass.jsoncouldnever enable the feature — only tests constructing
SassProcessordirectly. It is now plumbedthrough the configuration interface to the processor options.
serializeDeclarationMapmaps generated line 0 tosource 0, but
sourceswas populated in declaration order, so a partial that declared the firstclass became the primary source and navigating to the module import landed there. Index 0 is now
seeded with the stylesheet being compiled, with a regression assertion on
sources[0].boundary before the dot, so
.primary.secondaryrecorded onlyprimary, anddiv.onlymatchednothing at all. CSS Modules exports all of those names, so those declarations had no mapping.
Fixed with a negative lookbehind, plus a new fixture and test.
A fourth finding — watch-mode
unlinknot deleting an orphaned.d.ts.map, and no cleanup whenmaps are later disabled — is not addressed here. It is in
TypingsGenerator.ts, which mergedwith #5907 and is untouched by this PR; it seems better as a separate change than as an unrelated
edit to already-merged code. Happy to send that follow-up.
How it was tested
Validated on Linux / Node 22.
rush buildandrush testclean forheft-sass-plugin,typings-generator, andlocalization-utilities.heft-sass-plugin: 61/61 tests pass, including cases that decode the emitted map and assertthe resolved line:
_partial-with-class.scss, not the importing file;@mediaresolves to its primary rule;.primary.secondaryand indiv.qualifiedis mapped;sources[0]is the entry stylesheet even when a partial declares the first class;.d.ts.mapis emitted when the option is disabled.typings-generator: the existing tests from [typings-generator] Emit declaration source maps for generated typings #5907 still pass unmodified, confirming themulti-source change did not regress the single-source path.
go-to-definition on a CSS module class resolved to the
.scssrule rather than the generated.d.ts.Impacted documentation
heft-sass-plugingains agenerateDeclarationMapsoption; the plugin's configuration docs wouldneed a new entry. The JSON schema is updated in this PR.
rush changefiles are included, andcommon/reviews/api/typings-generator.api.mdis updated.