Skip to content

[heft-sass-plugin] Emit declaration source maps for generated typings - #5909

Open
Mike DelGaudio (mikedelgaudio) wants to merge 2 commits into
microsoft:mainfrom
mikedelgaudio:feature/heft-sass-plugin-declaration-maps
Open

[heft-sass-plugin] Emit declaration source maps for generated typings#5909
Mike DelGaudio (mikedelgaudio) wants to merge 2 commits into
microsoft:mainfrom
mikedelgaudio:feature/heft-sass-plugin-declaration-maps

Conversation

@mikedelgaudio

@mikedelgaudio Mike DelGaudio (mikedelgaudio) commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #5908

Sass typings are written into a folder that rootDirs merges into the source tree, so the language
service only ever sees the generated .d.ts. "Go to definition" on a CSS module class therefore
stops at the generated declaration instead of opening the rule that declares it.

This adds an opt-in generateDeclarationMaps option that emits a .d.ts.map beside each generated
typings 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 main and is standalone — it no longer carries
that 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:

  1. Request a source map from Sass when the option is enabled.
  2. Record where each class selector appears in the compiled CSS, using a PostCSS plugin registered
    before postcss-modules (which rewrites class names).
  3. Translate that position back through the Sass source map to the original stylesheet.
  4. createDTS reports 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:

  • A class declared in an @imported partial resolves into that partial. It does not appear in
    the importing file at all.
  • A class restated inside a @media or theme block still resolves to its primary rule, with no
    indentation heuristics.

Shared vs. Sass-specific code

To avoid a second copy of this logic:

Package Change
@rushstack/typings-generator serializeDeclarationMap now accepts multiple sources, and IDeclarationMapping gains an optional sourceIndex — needed because Sass declarations can originate from several files. Adds originalPositionFor, a small lookup helper over mappings decoded by @jridgewell/sourcemap-codec (the codec itself is deliberately just encode/decode).
@rushstack/heft-sass-plugin New SassDeclarationMaps.ts with 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-codec per the guidance on #5907; no hand-rolled VLQ
encoding or decoding is introduced.

Notes for reviewers:

  • Backwards compatible. serializeDeclarationMap's sources parameter widens from string to
    string | readonly string[], and sourceIndex defaults to 0, so existing callers are
    unaffected. The heft-localization-typings-plugin path is unchanged.
  • sourceMapIncludeSources is not forced on. Enabling declaration maps requests a Sass source
    map, but only embeds sources when sourceMap: true was already set, to avoid inflating output.
  • Happy to split the typings-generator half into its own PR if you would prefer to review the
    shared primitives separately.

Automated review feedback

Three real defects were flagged by the Copilot reviewer and are fixed here:

  • The option was unreachable from configuration. The schema accepted
    generateDeclarationMaps, but ISassConfigurationJson omitted it, so config/sass.json could
    never enable the feature — only tests constructing SassProcessor directly. It is now plumbed
    through the configuration interface to the processor options.
  • Source index 0 could be the wrong file. serializeDeclarationMap maps generated line 0 to
    source 0, but sources was populated in declaration order, so a partial that declared the first
    class 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].
  • Compound and element-qualified selectors dropped classes. The selector pattern required a
    boundary before the dot, so .primary.secondary recorded only primary, and div.only matched
    nothing 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 unlink not deleting an orphaned .d.ts.map, and no cleanup when
maps are later disabled — is not addressed here. It is in TypingsGenerator.ts, which merged
with #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 build and rush test clean for heft-sass-plugin, typings-generator, and
    localization-utilities.
  • heft-sass-plugin: 61/61 tests pass, including cases that decode the emitted map and assert
    the resolved line:
    • each declaration resolves to the rule that declares it;
    • a class declared in a partial resolves into _partial-with-class.scss, not the importing file;
    • a class restated inside @media resolves to its primary rule;
    • every class in .primary.secondary and in div.qualified is mapped;
    • sources[0] is the entry stylesheet even when a partial declares the first class;
    • no .d.ts.map is 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 the
    multi-source change did not regress the single-source path.
  • Separately verified the same approach against a live tsserver in a large internal monorepo:
    go-to-definition on a CSS module class resolved to the .scss rule rather than the generated
    .d.ts.

Impacted documentation

  • heft-sass-plugin gains a generateDeclarationMaps option; the plugin's configuration docs would
    need a new entry. The JSON schema is updated in this PR.
  • rush change files are included, and common/reviews/api/typings-generator.api.md is updated.

@dmichon-msft

Copy link
Copy Markdown
Contributor

The dependency is merged now, so please rebase

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 for second lands on the wrong declaration. Record each matched selector node's own line/column offset (for example via a selector AST) instead of reusing rule.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.
@mikedelgaudio
Mike DelGaudio (mikedelgaudio) force-pushed the feature/heft-sass-plugin-declaration-maps branch from 9a7ad7f to 43ea510 Compare August 5, 2026 20:16
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. Clear best when 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, so second maps to the wrong source rule. Record each matched class's actual line/column within rule.selector before 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.json options, but this new public option is absent. Add generateDeclarationMaps to 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 first root and misdirect a later local .root, while a valid escaped class such as .foo\:bar is recorded as foo and 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs triage

Development

Successfully merging this pull request may close these issues.

[heft-sass-plugin] Emit declaration source maps so "go to definition" resolves to the stylesheet

3 participants