Note
This document is machine-translated from docs/ts-plugin-internals.ja.md.
This document describes the internal architecture of the ts-plugin in CSS Modules Kit.
ts-plugin is a TypeScript Language Service Plugin. By using Volar.js to treat CSS as if it were a TypeScript file, it provides language features that span across CSS and TypeScript (such as Go to Definition and Find All References on CSS class names).
Specifically, when the TypeScript Language Service invokes Go to Definition or similar features, the request is forwarded to Volar.js. Volar.js generates a VirtualCode that represents the contents of a CSS file as a .d.ts, along with a mapping that associates CSS class names with positions in the .d.ts. Using these, it converts positions of symbols in the .d.ts into positions in the CSS file and returns them to the TypeScript Language Service, thereby realizing language features that span across CSS and TypeScript.
VirtualCode represents the contents of a CSS file as a TypeScript type definition file (.d.ts). It is generated by generateDts() in packages/core/src/dts-generator.ts. Here we describe its structure.
Suppose we have the following simple CSS file:
src/a.module.css:
.a_1 {
color: red;
}
.a_2 {
color: red;
}When generateDts() is called on this file, it generates the following type definition:
declare const styles = {
'a_1': '',
'a_2': '',
};This allows the TypeScript Language Service to assign the type { a_1: string; a_2: string; } to styles.
With only a type definition, CSS class names cannot be associated with positions in the generated TypeScript code, so features like Go to Definition and Find All References do not work correctly. Therefore, generateDts() also generates a mapping that represents this correspondence.
The mapping has the following structure:
interface CodeMapping {
generatedOffsets: number[]; // Offsets of code in the .d.ts
lengths: number[]; // Lengths of code in the .d.ts
sourceOffsets: number[]; // Offsets of code in the CSS
sourceLengths?: number[]; // Lengths of code in the CSS (if omitted, treated as the same as sourceOffsets)
}The .d.ts and mapping generated from the earlier src/a.module.css are as follows:
declare const styles = {
'a_1': '',
'a_2': '',
};
export default styles;{ sourceOffsets: [1, 22], lengths: [3, 3], generatedOffsets: [28, 41] }@import is a syntax for importing another stylesheet. Since the entire sheet is incorporated, writing @import './b.module.css' in src/a.module.css causes the tokens of ./b.module.css to be exported from src/a.module.css. To represent this as TypeScript types, CSS Modules Kit generates a type definition that re-exports all tokens of the imported CSS module.
For example, suppose we have the following CSS module:
src/a.module.css:
@import './b.module.css';For default export, it is represented with object spread:
function blockErrorType<T>(val: T): [0] extends [1 & T] ? {} : T;
declare const styles = {
...blockErrorType((await import('./b.module.css')).default),
};For named exports, it is represented with barrel re-export:
export * from './b.module.css';CSS Modules Kit takes the policy of including all @import declarations in the type definition regardless of whether the @import specifier can be resolved (#302). This makes the generated output independent of whether the import target file exists, which simplifies the implementation of watch mode and parallelization of code generation.
However, this policy has a side effect. For example, if the import target file does not exist or does not match the include/exclude of CSS Modules Kit, the type of (await import('./unresolved.module.css')).default becomes any. Spreading this directly would also turn the entire styles type into any, causing tokens that should exist (such as styles.a_1) to also become any.
To avoid this, CSS Modules Kit embeds a helper called blockErrorType<T> in the generated code (#303):
function blockErrorType<T>(val: T): [0] extends [1 & T] ? {} : T;This helper returns {} if T is any, and returns T as is otherwise. Since spreading {} does not break the types of other properties, it prevents the entire styles from being degraded to any even when there are unresolvable @import declarations.
@value ... from ... is a syntax for importing only specific tokens from another CSS module (optionally under different names). The imported tokens are exported from the importing file. For example, writing @value b_1, b_2 as aliased_b_2 from './b.module.css' in src/a.module.css causes b_1 from ./b.module.css to be exported from src/a.module.css as b_1, and b_2 to be exported as aliased_b_2. To represent this as TypeScript types, CSS Modules Kit generates a type definition that re-exports only the specified tokens.
For example, suppose we have the following CSS module:
src/a.module.css:
@value b_1, b_2 as aliased_b_2 from './b.module.css';For default export, the following type definition is generated:
declare const styles = {
'b_1': (await import('./b.module.css')).default['b_1'],
'aliased_b_2': (await import('./b.module.css')).default['b_2'],
};For named exports, the following type definition is generated:
export {
'b_1' as 'b_1',
'b_2' as 'aliased_b_2',
} from './b.module.css';generateDts() also generates a LinkedCodeMapping. This is a special mapping for linking two different symbols.
interface LinkedCodeMapping {
sourceOffsets: number[]; // Offsets of code A in the .d.ts
lengths: number[]; // Lengths of code A in the .d.ts
generatedOffsets: number[]; // Offsets of code B in the .d.ts
generatedLengths: number[]; // Lengths of code B in the .d.ts
}LinkedCodeMapping is used in edge cases such as when @value ... from ... is used with default export. For example, suppose we have the following CSS modules:
src/a.module.css:
@value b_1, b_2 as aliased_b_2 from './b.module.css';src/b.module.css:
.b_1 {
color: red;
}
.b_2 {
color: blue;
}generated/src/a.module.css.d.ts:
declare const styles = {
'b_1': (await import('./b.module.css')).default['b_1'],
'aliased_b_2': (await import('./b.module.css')).default['b_2'],
};In this case, the following LinkedCodeMapping is generated:
{ sourceOffsets: [27, 85], lengths: [5, 13], generatedOffsets: [75, 141], generatedLengths: [5, 5] }With this, when Find All References is invoked on b_1, both b_1 occurrences in a.module.css and b.module.css are returned. Similarly, when Find All References is invoked on aliased_b_2, b_2 and aliased_b_2 in a.module.css, as well as b_2 in b.module.css, are returned.
CSS class names may contain tokens that are invalid as JavaScript identifiers (for example, a-1). To support these, CSS Modules Kit wraps token names in single quotes.
For default export:
declare const styles = {
'a-1': '',
};For named export:
var _token_0: string;
export { _token_0 as 'a-1' };When the same token is defined multiple times, Go to Definition should be able to jump to all of those definitions. For example, suppose we have the following files:
src/a.module.css:
.a_1 {
color: red;
}
.a_1 {
color: red;
}src/a.ts:
import styles from './a.module.css';
styles.a_1;When Go to Definition is invoked on styles.a_1, it should be able to jump to both .a_1 definitions in src/a.module.css. To enable this, CSS Modules Kit generates the following type definition file and mapping for default export:
generated/src/a.module.css.d.ts:
declare const styles = {
'a_1': '',
'a_1': '',
};
export default styles;mapping:
{ sourceOffsets: [1, 24], lengths: [3, 3], generatedOffsets: [28, 41] }For named export, the following code and mapping are generated:
generated/src/a.module.css.d.ts:
var _token_0: string;
var _token_0: string;
export { _token_0 as 'a_1' };mapping:
{ sourceOffsets: [1, 24], lengths: [3, 3], generatedOffsets: [4, 26], generatedLengths: [8, 8] }The type definitions generated by CSS Modules Kit contain quoted property names and token names like 'a_1'. The TypeScript Language Service has a problem of returning different spans depending on the API.
For example, suppose we have the following type definition:
declare const styles = {
'a_1': string,
};The return values of each API for a_1 in styles.a_1 are as follows:
| API | span | Includes quotes |
|---|---|---|
getDefinitionAtPosition |
{ start: 27, length: 5 } |
Yes |
findReferences |
{ start: 28, length: 3 } |
No |
findRenameLocations |
{ start: 28, length: 3 } |
No |
Only getDefinitionAtPosition returns a span that includes the quotes. This is the behavior of TypeScript itself.
And when this mismatch is combined with Volar.js's mapping, it becomes a problem. For example, if we register a mapping like { generatedOffsets: [28], lengths: [3], sourceOffsets: [1] } that covers only the inside of the quotes:
findReferencesreturns{ start: 28, length: 3 }, which directly matches the mapping and is converted to position1in the CSS.getDefinitionAtPositionreturns{ start: 27, length: 5 }, which falls outside the mapping range, fails to match, and cannot find the position in the CSS.
Conversely, if we register a mapping that includes the quotes, findReferences will then be converted to an incorrect position. Even if both ranges are combined into a single mapping, Volar.js cannot handle overlapping ranges within a single mapping, so it does not work correctly (volarjs/volar.js#203).
CSS Modules Kit avoids this problem by:
- Not including the quotes in the registered mapping (in the example above, only offset 28, length 3)
- Replacing Volar.js's mapper to add a fallback that strips one outer character and retries when there is no direct match
The fallback is implemented in CustomSourceMap in packages/ts-plugin/src/source-map.ts, and is enabled by replacing language.mapperFactory in packages/ts-plugin/src/index.cts.
With this, getDefinitionAtPosition's { start: 27, length: 5 } also matches the mapping by retrying with the inner { start: 28, length: 3 }.
Reference: mizdra/volar-single-quote-span-problem