From bc5d7dc777d98d73319ac070060c88ac3dc17bf1 Mon Sep 17 00:00:00 2001 From: Mike DelGaudio <43451174+mikedelgaudio@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:10:59 -0700 Subject: [PATCH 1/2] Emit declaration source maps for generated typings 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. --- .../typings-generator/src/DeclarationMap.ts | 69 +++++++++++++++++-- libraries/typings-generator/src/index.ts | 7 +- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/libraries/typings-generator/src/DeclarationMap.ts b/libraries/typings-generator/src/DeclarationMap.ts index cc543c8dd4..91c547ff08 100644 --- a/libraries/typings-generator/src/DeclarationMap.ts +++ b/libraries/typings-generator/src/DeclarationMap.ts @@ -34,6 +34,14 @@ export interface IDeclarationMapping { * The zero-based position in the source file that produced this declaration. */ sourcePosition: ISourcePosition; + + /** + * The index into the `sources` passed to {@link serializeDeclarationMap}. Defaults to `0`, which + * is correct whenever the typings are produced from a single source file. Generators whose output + * can draw on more than one input - for example Sass, where a class may be declared in an + * imported partial - set this to identify the declaring file. + */ + sourceIndex?: number; } const SOURCE_MAP_VERSION: 3 = 3; @@ -55,7 +63,9 @@ interface ISourceMap { * @param mappings - The positions to map. Generated lines are relative to the typings content * produced by the parser, before `generatedLineOffset` is applied. * @param generatedFileName - The file name of the generated typings, used as the map's `file`. - * @param sourcePath - The path of the source file, relative to the folder containing the map. + * @param sources - The path of the source file, relative to the folder containing the map. An + * array may be supplied when declarations can originate from more than one file, in which case + * each mapping selects one via `sourceIndex`. * @param generatedLineOffset - The number of header lines prepended to the generated typings. * * @public @@ -63,7 +73,7 @@ interface ISourceMap { export function serializeDeclarationMap( mappings: readonly IDeclarationMapping[], generatedFileName: string, - sourcePath: string, + sources: string | readonly string[], generatedLineOffset: number ): string { // The module's own declaration span starts at the beginning of the generated file, so line 0 is @@ -80,7 +90,7 @@ export function serializeDeclarationMap( // The codec takes absolute positions and performs the relative encoding itself. segmentsByLine[generatedLine].push([ mapping.generatedColumn, - 0, + mapping.sourceIndex ?? 0, mapping.sourcePosition.line, mapping.sourcePosition.column ]); @@ -94,10 +104,61 @@ export function serializeDeclarationMap( version: SOURCE_MAP_VERSION, file: generatedFileName, sourceRoot: '', - sources: [sourcePath], + sources: typeof sources === 'string' ? [sources] : [...sources], names: [], mappings: encode(segmentsByLine) }; return JSON.stringify(sourceMap); } + +type IMappedSegment = [number, number, number, number] | [number, number, number, number, number]; + +/** A segment with only a generated column marks output that has no counterpart in any source. */ +function isMappedSegment(segment: SourceMapSegment): segment is IMappedSegment { + return segment.length >= 4; +} + +/** + * Looks up the original position for a position in generated output, given source map mappings that + * have been decoded with `@jridgewell/sourcemap-codec`. Returns `undefined` when the line has no + * mapping. + * + * A generator that compiles its input before producing typings - for example Sass - needs this to + * translate a position in the compiled output back to the file the developer wrote. + * + * @public + */ +export function originalPositionFor( + decoded: readonly SourceMapSegment[][], + line: number, + column: number +): { sourceIndex: number; line: number; column: number } | undefined { + const segments: readonly SourceMapSegment[] | undefined = decoded[line]; + if (!segments || segments.length === 0) { + return undefined; + } + + let best: IMappedSegment | undefined; + for (const segment of segments) { + if (segment[0] > column) { + break; + } + + if (isMappedSegment(segment)) { + best = segment; + } + } + + // The construct may begin before the first mapped column on its line; falling back to the first + // mapped segment still lands on the correct line. + if (!best) { + best = segments.find(isMappedSegment); + } + + if (!best) { + return undefined; + } + + return { sourceIndex: best[1], line: best[2], column: best[3] }; +} diff --git a/libraries/typings-generator/src/index.ts b/libraries/typings-generator/src/index.ts index 296ff728f1..fe3b6acd47 100644 --- a/libraries/typings-generator/src/index.ts +++ b/libraries/typings-generator/src/index.ts @@ -9,7 +9,12 @@ * @packageDocumentation */ -export { type ISourcePosition, type IDeclarationMapping, serializeDeclarationMap } from './DeclarationMap'; +export { + type ISourcePosition, + type IDeclarationMapping, + serializeDeclarationMap, + originalPositionFor +} from './DeclarationMap'; export { type ReadFile, From effc406a27f1c0e7f440e169f4dbba66eb594282 Mon Sep 17 00:00:00 2001 From: Mike DelGaudio <43451174+mikedelgaudio@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:29:40 -0700 Subject: [PATCH 2/2] [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. --- ...-declaration-maps_2026-07-29-20-30-00.json | 10 ++ ...tion-map-decoding_2026-07-29-20-30-00.json | 10 ++ .../config/subspaces/default/pnpm-lock.yaml | 6 + common/reviews/api/typings-generator.api.md | 11 +- heft-plugins/heft-sass-plugin/package.json | 2 + .../src/SassDeclarationMaps.ts | 162 ++++++++++++++++++ .../heft-sass-plugin/src/SassPlugin.ts | 5 +- .../heft-sass-plugin/src/SassProcessor.ts | 133 ++++++++++++-- heft-plugins/heft-sass-plugin/src/index.ts | 8 + .../src/schemas/heft-sass-plugin.schema.json | 5 + .../src/test/SassProcessor.test.ts | 149 +++++++++++++++- .../__snapshots__/SassProcessor.test.ts.snap | 76 ++++++++ .../test/fixtures/_partial-with-class.scss | 5 + .../fixtures/compound-selectors.module.scss | 9 + .../test/fixtures/partial-class.module.scss | 13 ++ 15 files changed, 589 insertions(+), 15 deletions(-) create mode 100644 common/changes/@rushstack/heft-sass-plugin/feature-heft-sass-plugin-declaration-maps_2026-07-29-20-30-00.json create mode 100644 common/changes/@rushstack/typings-generator/feature-declaration-map-decoding_2026-07-29-20-30-00.json create mode 100644 heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/_partial-with-class.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/compound-selectors.module.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/partial-class.module.scss diff --git a/common/changes/@rushstack/heft-sass-plugin/feature-heft-sass-plugin-declaration-maps_2026-07-29-20-30-00.json b/common/changes/@rushstack/heft-sass-plugin/feature-heft-sass-plugin-declaration-maps_2026-07-29-20-30-00.json new file mode 100644 index 0000000000..a6f11b3a08 --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/feature-heft-sass-plugin-declaration-maps_2026-07-29-20-30-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-sass-plugin", + "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.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-sass-plugin" +} diff --git a/common/changes/@rushstack/typings-generator/feature-declaration-map-decoding_2026-07-29-20-30-00.json b/common/changes/@rushstack/typings-generator/feature-declaration-map-decoding_2026-07-29-20-30-00.json new file mode 100644 index 0000000000..20b4edccfe --- /dev/null +++ b/common/changes/@rushstack/typings-generator/feature-declaration-map-decoding_2026-07-29-20-30-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/typings-generator", + "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.", + "type": "minor" + } + ], + "packageName": "@rushstack/typings-generator" +} diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 4ec1b48ee8..2cd8e13d29 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3415,9 +3415,15 @@ importers: ../../../heft-plugins/heft-sass-plugin: dependencies: + '@jridgewell/sourcemap-codec': + specifier: ~1.5.5 + version: 1.5.5 '@rushstack/node-core-library': specifier: workspace:* version: link:../../libraries/node-core-library + '@rushstack/typings-generator': + specifier: workspace:* + version: link:../../libraries/typings-generator '@types/tapable': specifier: 1.0.6 version: 1.0.6 diff --git a/common/reviews/api/typings-generator.api.md b/common/reviews/api/typings-generator.api.md index ca5acb0922..d52afd63d6 100644 --- a/common/reviews/api/typings-generator.api.md +++ b/common/reviews/api/typings-generator.api.md @@ -5,11 +5,13 @@ ```ts import { ITerminal } from '@rushstack/terminal'; +import { SourceMapSegment } from '@jridgewell/sourcemap-codec'; // @public export interface IDeclarationMapping { generatedColumn: number; generatedLine: number; + sourceIndex?: number; sourcePosition: ISourcePosition; } @@ -104,11 +106,18 @@ export interface ITypingsGeneratorOptionsWithoutReadFile TTypingsResult | Promise; } +// @public +export function originalPositionFor(decoded: readonly SourceMapSegment[][], line: number, column: number): { + sourceIndex: number; + line: number; + column: number; +} | undefined; + // @public (undocumented) export type ReadFile = (filePath: string, relativePath: string) => Promise | TFileContents; // @public -export function serializeDeclarationMap(mappings: readonly IDeclarationMapping[], generatedFileName: string, sourcePath: string, generatedLineOffset: number): string; +export function serializeDeclarationMap(mappings: readonly IDeclarationMapping[], generatedFileName: string, sources: string | readonly string[], generatedLineOffset: number): string; // @public export class StringValuesTypingsGenerator extends TypingsGenerator { diff --git a/heft-plugins/heft-sass-plugin/package.json b/heft-plugins/heft-sass-plugin/package.json index 09b3b76d1e..4164603654 100644 --- a/heft-plugins/heft-sass-plugin/package.json +++ b/heft-plugins/heft-sass-plugin/package.json @@ -46,7 +46,9 @@ "@rushstack/heft": "^1.2.22" }, "dependencies": { + "@jridgewell/sourcemap-codec": "~1.5.5", "@rushstack/node-core-library": "workspace:*", + "@rushstack/typings-generator": "workspace:*", "@types/tapable": "1.0.6", "postcss": "~8.5.10", "postcss-modules": "~6.0.0", diff --git a/heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts b/heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts new file mode 100644 index 0000000000..7a363e15d2 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/SassDeclarationMaps.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { Plugin as PostcssPlugin, Rule } from 'postcss'; +import { decode, type SourceMapSegment } from '@jridgewell/sourcemap-codec'; + +import { originalPositionFor, type ISourcePosition } from '@rushstack/typings-generator'; + +/** + * The location of a class declaration in the original stylesheet. The class may be declared in an + * imported partial rather than the entry file, so the file is tracked alongside the position. + * + * @public + */ +export interface IResolvedClassPosition extends ISourcePosition { + /** Absolute path of the stylesheet that declares the class. */ + absoluteSourcePath: string; +} + +/** + * The subset of a raw source map consumed when resolving positions. + * + * @public + */ +export interface IRawSourceMap { + sources: string[]; + mappings: string; + sourceRoot?: string; +} + +/** + * Records where each class selector first appears in the CSS being processed. + * + * @public + */ +export interface IClassPositionRecorder { + /** Must be registered before `postcss-modules`, which rewrites class names. */ + plugin: PostcssPlugin; + positions: Map; +} + +/** + * Matches each class in a selector, capturing its name. + * + * Every class is captured, including each one in a compound selector such as `.primary.secondary` + * and a class qualified by an element such as `div.only`, because CSS Modules exports all of them + * and each therefore needs a mapping. The negative lookbehind skips an escaped dot so that a + * literal `.` inside a name is not treated as the start of another class. + */ +const CLASS_SELECTOR_REGEXP: RegExp = /(? = new Map(); + + const plugin: PostcssPlugin = { + postcssPlugin: 'rushstack-record-class-positions', + Rule(rule: Rule): void { + const start: { line: number; column: number } | undefined = rule.source?.start; + if (!start) { + return; + } + + // PostCSS positions are one-based. + const position: ISourcePosition = { line: start.line - 1, column: start.column - 1 }; + + for (const selector of rule.selectors) { + CLASS_SELECTOR_REGEXP.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = CLASS_SELECTOR_REGEXP.exec(selector)) !== null) { + if (!positions.has(match[1])) { + positions.set(match[1], position); + } + } + } + } + }; + + return { plugin, positions }; +} + +/** + * Converts a `sources` entry from a Sass source map into an absolute file path. Sass emits `file:` + * URLs by default, but a compilation driven through a custom importer may use another scheme, in + * which case the caller supplies its own resolver. + * + * @public + */ +export function resolveSourceUrl(source: string, baseFolder: string): string { + if (source.startsWith('file:')) { + return fileURLToPath(source); + } + + return path.resolve(baseFolder, source); +} + +/** + * Translates recorded compiled-CSS positions back to the original stylesheets, using the source map + * that Sass produced for the compilation. + * + * Classes whose position cannot be mapped are omitted, leaving navigation for those names + * unchanged. + * + * `resolveSourcePath` converts a `sources` entry from the Sass source map into an absolute file + * path; it defaults to {@link resolveSourceUrl}. + * + * @public + */ +export function resolveStylesheetPositions( + cssPositions: ReadonlyMap, + sassSourceMap: IRawSourceMap, + baseFolder: string, + resolveSourcePath: (source: string, baseFolder: string) => string = resolveSourceUrl +): Map { + const resolved: Map = new Map(); + const decoded: SourceMapSegment[][] = decode(sassSourceMap.mappings); + const sourceRoot: string = sassSourceMap.sourceRoot ? sassSourceMap.sourceRoot.replace(/\/?$/, '/') : ''; + + const absoluteSources: (string | undefined)[] = sassSourceMap.sources.map((source: string) => { + try { + return resolveSourcePath(`${sourceRoot}${source}`, baseFolder); + } catch { + // An unrecognized source is skipped rather than failing the build. + return undefined; + } + }); + + for (const [className, cssPosition] of cssPositions) { + const original: { sourceIndex: number; line: number; column: number } | undefined = originalPositionFor( + decoded, + cssPosition.line, + cssPosition.column + ); + if (!original) { + continue; + } + + const absoluteSourcePath: string | undefined = absoluteSources[original.sourceIndex]; + if (!absoluteSourcePath) { + continue; + } + + resolved.set(className, { + absoluteSourcePath, + line: original.line, + column: original.column + }); + } + + return resolved; +} diff --git a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts index 32d1ef5a74..1429988462 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts @@ -32,6 +32,7 @@ export interface ISassConfigurationJson { doNotTrimOriginalFileExtension?: boolean; preserveIcssExports?: boolean; sourceMap?: boolean; + generateDeclarationMaps?: boolean; } const SASS_CONFIGURATION_LOCATION: string = 'config/sass.json'; @@ -102,7 +103,8 @@ export default class SassPlugin implements IHeftPlugin { excludeFiles, doNotTrimOriginalFileExtension, preserveIcssExports, - sourceMap + sourceMap, + generateDeclarationMaps } = sassConfigurationJson || {}; function resolveFolder(folder: string): string { @@ -132,6 +134,7 @@ export default class SassPlugin implements IHeftPlugin { doNotTrimOriginalFileExtension, preserveIcssExports, sourceMap, + generateDeclarationMaps, postProcessCssAsync: hooks.postProcessCss.isUsed() ? async (cssText: string) => hooks.postProcessCss.promise(cssText) : undefined diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 76a22befe6..5e07e8b8cf 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -33,6 +33,19 @@ import { RealNodeModulePathResolver, Sort } from '@rushstack/node-core-library'; +import { + serializeDeclarationMap, + type IDeclarationMapping, + type ISourcePosition +} from '@rushstack/typings-generator'; + +import { + createClassPositionRecorder, + resolveSourceUrl, + resolveStylesheetPositions, + type IClassPositionRecorder, + type IResolvedClassPosition +} from './SassDeclarationMaps'; const SIMPLE_IDENTIFIER_REGEX: RegExp = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; @@ -136,6 +149,19 @@ export interface ISassProcessorOptions { */ sourceMap?: boolean; + /** + * If true, a `.d.ts.map` file is emitted next to each generated typings file. This allows editors + * to resolve "go to definition" on a CSS module class to the rule that declares it in the + * stylesheet, instead of stopping at the generated typings. + * + * Enabling this requests a source map from the Sass compiler even when `sourceMap` is false, + * because the declaration map is built by translating positions in the compiled CSS back to the + * original stylesheet. + * + * Defaults to false. + */ + generateDeclarationMaps?: boolean; + /** * A callback to further modify the raw CSS text after it has been generated. Only relevant if emitting CSS files. */ @@ -271,7 +297,10 @@ export class SassProcessor { } ], silenceDeprecations: deprecationsToSilence, - ...(options.sourceMap && { sourceMap: true, sourceMapIncludeSources: true }) + ...((options.sourceMap || options.generateDeclarationMaps) && { + sourceMap: true, + sourceMapIncludeSources: options.sourceMap === true + }) }; } @@ -780,11 +809,13 @@ export class SassProcessor { doNotTrimOriginalFileExtension, postProcessCssAsync, preserveIcssExports, - sourceMap + sourceMap, + generateDeclarationMaps } = this._options; // Handle CSS modules let moduleMap: JsonObject | undefined; + let classPositions: ReadonlyMap | undefined; if (record.isModule) { const postCssModules: postcss.Plugin = cssModules({ getJSON: (cssFileName: string, json: JsonObject) => { @@ -795,8 +826,14 @@ export class SassProcessor { generateScopedName: (name: string) => name }); + // The recorder must run before postcss-modules, which rewrites class names. + const recorder: IClassPositionRecorder | undefined = generateDeclarationMaps + ? createClassPositionRecorder() + : undefined; + classPositions = recorder?.positions; + const postCssResult: postcss.Result = await postcss - .default([postCssModules]) + .default(recorder ? [recorder.plugin, postCssModules] : [postCssModules]) .process(css, { from: sourceFilePath }); if (!preserveIcssExports) { @@ -818,18 +855,80 @@ export class SassProcessor { // default export at runtime, so treat it as a side-effect-only import just like // a non-module file. const hasModuleExports: boolean | undefined = moduleMap && Object.keys(moduleMap).length > 0; - const dtsContent: string = createDTS(moduleMap, exportAsDefault, hasModuleExports); + const declarationPositions: Map | undefined = classPositions + ? new Map() + : undefined; + const dtsContent: string = createDTS(moduleMap, exportAsDefault, hasModuleExports, declarationPositions); const writeFileOptions: IFileSystemWriteFileOptions = { ensureFolderExists: true }; - for (const dtsOutputFolder of dtsOutputFolders) { - await FileSystem.writeFileAsync( - path.resolve(dtsOutputFolder, `${relativeFilePath}.d.ts`), - dtsContent, - writeFileOptions + const declarationMappings: IDeclarationMapping[] = []; + const declarationMapSources: string[] = []; + if (classPositions && declarationPositions && result.sourceMap) { + const sourcePositions: Map = resolveStylesheetPositions( + classPositions, + result.sourceMap, + path.dirname(sourceFilePath), + (source: string, baseFolder: string) => + source.startsWith('heft:') ? heftUrlToPath(source) : resolveSourceUrl(source, baseFolder) ); + + // Index 0 must be the stylesheet being compiled: serializeDeclarationMap maps generated + // line 0 to source 0, so that entry determines where navigating to the module itself lands. + // Populating in declaration order would otherwise make an imported partial the primary + // source whenever it happens to declare the first class. + const sourceIndexByPath: Map = new Map([[sourceFilePath, 0]]); + declarationMapSources.push(sourceFilePath); + + for (const [className, generated] of declarationPositions) { + const source: IResolvedClassPosition | undefined = sourcePositions.get(className); + if (!source) { + continue; + } + + let sourceIndex: number | undefined = sourceIndexByPath.get(source.absoluteSourcePath); + if (sourceIndex === undefined) { + sourceIndex = declarationMapSources.length; + sourceIndexByPath.set(source.absoluteSourcePath, sourceIndex); + declarationMapSources.push(source.absoluteSourcePath); + } + + declarationMappings.push({ + generatedLine: generated.line, + generatedColumn: generated.column, + sourcePosition: { line: source.line, column: source.column }, + sourceIndex + }); + } + } + + for (const dtsOutputFolder of dtsOutputFolders) { + const dtsFilePath: string = path.resolve(dtsOutputFolder, `${relativeFilePath}.d.ts`); + await FileSystem.writeFileAsync(dtsFilePath, dtsContent, writeFileOptions); + + // A file whose classes could not be mapped gets no map at all, which leaves navigation for + // that file exactly as it is without this feature. + if (declarationMappings.length > 0) { + const dtsFolder: string = path.dirname(dtsFilePath); + // Source map paths are POSIX-style regardless of platform, and are relative to the folder + // containing the map, so they are recomputed for each output folder. + const relativeSources: string[] = declarationMapSources.map((absoluteSourcePath: string) => + Path.convertToSlashes(path.relative(dtsFolder, absoluteSourcePath)) + ); + + await FileSystem.writeFileAsync( + `${dtsFilePath}.map`, + serializeDeclarationMap( + declarationMappings, + `${path.basename(relativeFilePath)}.d.ts`, + relativeSources, + 0 + ), + writeFileOptions + ); + } } if (cssOutputFolders && cssOutputFolders.length > 0) { @@ -899,13 +998,20 @@ export class SassProcessor { function createDTS( moduleMap: JsonObject | undefined, exportAsDefault: boolean, - hasModuleExports: boolean | undefined + hasModuleExports: boolean | undefined, + declarationPositions?: Map +): string; +function createDTS( + moduleMap: JsonObject, + exportAsDefault: boolean, + hasModuleExports: true, + declarationPositions?: Map ): string; -function createDTS(moduleMap: JsonObject, exportAsDefault: boolean, hasModuleExports: true): string; function createDTS( moduleMap: JsonObject | undefined, exportAsDefault: boolean, - hasModuleExports: boolean | undefined + hasModuleExports: boolean | undefined, + declarationPositions?: Map ): string { if (hasModuleExports) { // Create a source file. @@ -918,6 +1024,7 @@ function createDTS( ? className : JSON.stringify(className); // Quote and escape class names as needed. + declarationPositions?.set(className, { line: source.length, column: 2 }); source.push(` ${safeClassName}: string;`); } @@ -932,12 +1039,14 @@ function createDTS( ); } + declarationPositions?.set(className, { line: source.length, column: 'export const '.length }); source.push(`export const ${className}: string;`); } } return source.join('\n'); } else { + declarationPositions?.clear(); return `export {};`; } } diff --git a/heft-plugins/heft-sass-plugin/src/index.ts b/heft-plugins/heft-sass-plugin/src/index.ts index 91ca269f56..e7cec388a2 100644 --- a/heft-plugins/heft-sass-plugin/src/index.ts +++ b/heft-plugins/heft-sass-plugin/src/index.ts @@ -3,3 +3,11 @@ export { PLUGIN_NAME as SassPluginName } from './constants'; export type { ISassPluginAccessor } from './SassPlugin'; +export { + createClassPositionRecorder, + resolveStylesheetPositions, + resolveSourceUrl, + type IClassPositionRecorder, + type IResolvedClassPosition, + type IRawSourceMap +} from './SassDeclarationMaps'; diff --git a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json index 71d163bff9..780327d478 100644 --- a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json +++ b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json @@ -121,6 +121,11 @@ "sourceMap": { "type": "boolean", "description": "If true, a `.css.map` source map file will be written next to each emitted `.css` file, and a `sourceMappingURL` comment will be appended to the `.css`. Defaults to `false`." + }, + + "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`." } } } diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 4faa802770..9a45d308c8 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -29,6 +29,7 @@ type ICreateProcessorOptions = Partial< | 'dtsOutputFolders' | 'exportAsDefault' | 'fileExtensions' + | 'generateDeclarationMaps' | 'nonModuleFileExtensions' | 'postProcessCssAsync' | 'preserveIcssExports' @@ -154,7 +155,7 @@ describe(SassProcessor.name, () => { // Source map contents include the absolute-relative path back to the source file and the // verbatim source file bytes. Both vary by checkout location and OS line endings, which makes // raw snapshots non-portable. Normalize them to stable forms before storing. - if (filePath.endsWith('.css.map')) { + if (filePath.endsWith('.map')) { serialized = normalizeSourceMapForSnapshot(serialized); } writtenFiles.set(filePath, serialized); @@ -826,4 +827,150 @@ describe(SassProcessor.name, () => { expect(css).toMatch(/\/\*# sourceMappingURL=classes-and-exports\.module\.scss\.css\.map \*\//); }); }); + + describe('declaration maps', () => { + interface IDecodedMapping { + generatedLine: number; + name: string; + source: string; + sourceLine: number; + } + + /** + * Decodes a `.d.ts.map` and pairs each mapping with the declaration text on the generated line + * it points at, so assertions can be written in terms of class names rather than offsets. + */ + function decodeDeclarationMap(mapJson: string, dtsContent: string): IDecodedMapping[] { + const map: { sources: string[]; mappings: string } = JSON.parse(mapJson); + const dtsLines: string[] = dtsContent.split('\n'); + const base64: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + + const results: IDecodedMapping[] = []; + let sourceIndex: number = 0; + let sourceLine: number = 0; + let generatedLine: number = 0; + + for (const lineText of map.mappings.split(';')) { + if (lineText) { + for (const segmentText of lineText.split(',')) { + let index: number = 0; + const readVlq: () => number = () => { + let result: number = 0; + let shift: number = 0; + let isContinuation: boolean = true; + while (isContinuation) { + const digit: number = base64.indexOf(segmentText[index++]); + /* eslint-disable no-bitwise */ + isContinuation = (digit & 32) !== 0; + result += (digit & 31) << shift; + shift += 5; + } + const isNegative: boolean = (result & 1) === 1; + result >>>= 1; + /* eslint-enable no-bitwise */ + return isNegative ? -result : result; + }; + + readVlq(); // generated column + if (index < segmentText.length) { + sourceIndex += readVlq(); + sourceLine += readVlq(); + readVlq(); // source column + const declaration: string = (dtsLines[generatedLine] || '').trim(); + const nameMatch: RegExpMatchArray | null = declaration.match(/([A-Za-z_$][A-Za-z0-9_$]*)\s*:/); + results.push({ + generatedLine, + name: nameMatch ? nameMatch[1] : '', + source: map.sources[sourceIndex], + sourceLine + }); + } + } + } + + generatedLine++; + } + + return results; + } + + it('does not emit a map when the option is disabled', async () => { + const { processor } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + + expect(getAllWrittenPathsMatching('.d.ts.map')).toHaveLength(0); + }); + + it('resolves each declaration to the rule that declares it', async () => { + const { processor } = createProcessor(terminalProvider, { + generateDeclarationMaps: true, + exportAsDefault: false + }); + await compileFixtureAsync(processor, 'classes-and-exports.module.scss'); + + const decoded: IDecodedMapping[] = decodeDeclarationMap( + getWrittenFile('classes-and-exports.module.scss.d.ts.map'), + getDtsOutput('classes-and-exports.module.scss') + ); + + // .root is declared on line 2 and .highlighted on line 7 of the fixture (one-based). + const byName: Map = new Map(decoded.map((m) => [m.name, m])); + expect(byName.get('root')?.sourceLine).toBe(1); + expect(byName.get('highlighted')?.sourceLine).toBe(6); + }); + + it('maps every class in a compound or element-qualified selector', async () => { + const { processor } = createProcessor(terminalProvider, { + generateDeclarationMaps: true, + exportAsDefault: false + }); + await compileFixtureAsync(processor, 'compound-selectors.module.scss'); + + const decoded: IDecodedMapping[] = decodeDeclarationMap( + getWrittenFile('compound-selectors.module.scss.d.ts.map'), + getDtsOutput('compound-selectors.module.scss') + ); + const byName: Map = new Map(decoded.map((m) => [m.name, m])); + + // Both classes of `.primary.secondary` are exported, so both need a mapping. + expect(byName.get('primary')?.sourceLine).toBe(2); + expect(byName.get('secondary')?.sourceLine).toBe(2); + // A class qualified by an element is still the subject of the rule. + expect(byName.get('qualified')?.sourceLine).toBe(6); + }); + + it('resolves a class declared in a partial into that partial', async () => { + const { processor } = createProcessor(terminalProvider, { + generateDeclarationMaps: true, + exportAsDefault: false + }); + await compileFixtureAsync(processor, 'partial-class.module.scss'); + + const decoded: IDecodedMapping[] = decodeDeclarationMap( + getWrittenFile('partial-class.module.scss.d.ts.map'), + getDtsOutput('partial-class.module.scss') + ); + const byName: Map = new Map(decoded.map((m) => [m.name, m])); + + // The class is declared in _partial-with-class.scss, not in the file that imports it. + expect(byName.get('fromPartial')?.source).toMatch(/_partial-with-class\.scss$/); + expect(byName.get('fromPartial')?.sourceLine).toBe(2); + + // Sources must be plain relative paths. A URL scheme surviving into the map would still end + // with the right file name while being unresolvable by an editor. + for (const mapping of decoded) { + expect(mapping.source).not.toMatch(/^[A-Za-z][A-Za-z0-9+.-]*:/); + } + + // .localClass is restated inside a media query; the primary rule wins. + expect(byName.get('localClass')?.source).toMatch(/partial-class\.module\.scss$/); + expect(byName.get('localClass')?.sourceLine).toBe(4); + + // Generated line 0 is always mapped to source index 0, so the entry stylesheet must occupy + // that slot even though a partial declares the first class. Otherwise navigating to the + // module itself would land in the partial. + const map: { sources: string[] } = JSON.parse(getWrittenFile('partial-class.module.scss.d.ts.map')); + expect(map.sources[0]).toMatch(/partial-class\.module\.scss$/); + }); + }); }); diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap index d2fab19a07..be3a22e8de 100644 --- a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -366,6 +366,82 @@ export default styles;", } `; +exports[`SassProcessor declaration maps does not emit a map when the option is disabled: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor declaration maps does not emit a map when the option is disabled: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "declare interface IStyles { + themeColor: string; + spacing: string; + root: string; + highlighted: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/classes-and-exports.module.css" => ".root { + color: red; + font-size: 14px; +} + +.highlighted { + background-color: yellow; +}", +} +`; + +exports[`SassProcessor declaration maps maps every class in a compound or element-qualified selector: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor declaration maps maps every class in a compound or element-qualified selector: written-files 1`] = ` +Map { + "/fake/output/dts/compound-selectors.module.scss.d.ts" => "export const primary: string; +export const secondary: string; +export const qualified: string;", + "/fake/output/dts/compound-selectors.module.scss.d.ts.map" => "{\\"version\\":3,\\"file\\":\\"compound-selectors.module.scss.d.ts\\",\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/compound-selectors.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAAA,aAEA;aAAA;aAIA\\"}", +} +`; + +exports[`SassProcessor declaration maps resolves a class declared in a partial into that partial: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor declaration maps resolves a class declared in a partial into that partial: written-files 1`] = ` +Map { + "/fake/output/dts/partial-class.module.scss.d.ts" => "export const fromPartial: string; +export const localClass: string;", + "/fake/output/dts/partial-class.module.scss.d.ts.map" => "{\\"version\\":3,\\"file\\":\\"partial-class.module.scss.d.ts\\",\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/partial-class.module.scss\\",\\"fixtures/_partial-with-class.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAAA,aCEA;aDEA\\"}", +} +`; + +exports[`SassProcessor declaration maps resolves each declaration to the rule that declares it: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor declaration maps resolves each declaration to the rule that declares it: written-files 1`] = ` +Map { + "/fake/output/dts/classes-and-exports.module.scss.d.ts" => "export const themeColor: string; +export const spacing: string; +export const root: string; +export const highlighted: string;", + "/fake/output/dts/classes-and-exports.module.scss.d.ts.map" => "{\\"version\\":3,\\"file\\":\\"classes-and-exports.module.scss.d.ts\\",\\"sourceRoot\\":\\"\\",\\"sources\\":[\\"fixtures/classes-and-exports.module.scss\\"],\\"names\\":[],\\"mappings\\":\\"AAAA;;aACA;aAKA\\"}", +} +`; + exports[`SassProcessor doNotTrimOriginalFileExtension preserves the source extension when doNotTrimOriginalFileExtension is true: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/_partial-with-class.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/_partial-with-class.scss new file mode 100644 index 0000000000..bb78067017 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/_partial-with-class.scss @@ -0,0 +1,5 @@ +// Sass partial that declares a class, used to verify that a declaration map resolves a class +// back into the partial that declares it rather than the file that imports it. +.fromPartial { + color: green; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/compound-selectors.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/compound-selectors.module.scss new file mode 100644 index 0000000000..48dbe64974 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/compound-selectors.module.scss @@ -0,0 +1,9 @@ +// Classes that only ever appear in a compound selector or qualified by an element. CSS Modules +// exports all of them, so each needs a mapping in the declaration map. +.primary.secondary { + color: red; +} + +div.qualified { + color: blue; +} diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/partial-class.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/partial-class.module.scss new file mode 100644 index 0000000000..cf24dc032c --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/partial-class.module.scss @@ -0,0 +1,13 @@ +// Imports a partial that declares a class, and restates a class inside a media query so that the +// declaration map can be checked for preferring the primary rule. +@use 'partial-with-class'; + +.localClass { + padding: 4px; +} + +@media (max-width: 600px) { + .localClass { + padding: 0; + } +}