@flarian/language-plugin-css-modules
v1.0.0
Published
Vue language tools plugin: types for external CSS modules straight from PostCSS, without .d.ts files
Maintainers
Readme
@flarian/language-plugin-css-modules
Types for external CSS modules in Vue single-file components, computed by the Vue language tools themselves.
No .d.ts files on disk, no build step, no watcher.
<style module="styles" src="./styles.module.pcss" lang="pcss" />With the plugin, styles.hint is checked against the real class names of styles.module.pcss — in the editor and in vue-tsc alike.
A typo is an error, and go-to-definition jumps into the stylesheet.
Why
Volar reads class names only from the inline content of a <style module> block.
For src="…" it emits typeof import('./styles.module.pcss').default and leaves the rest to TypeScript module resolution.
Vite declares module '*.module.pcss' with an index signature.
So that import resolves to "any string key", and every typo passes silently.
The common workaround writes a .d.ts next to each stylesheet.
This plugin instead turns every *.module.pcss into a virtual TypeScript module inside the language service.
Class names are taken after PostCSS runs, so classes born from transformations are typed too:
.hint-test {
&-deep {
color: blue;
}
}styles.hintTest; // ok
styles.hintTestDeep; // okInstall
pnpm add -D @flarian/language-plugin-css-modulespostcss is a peer dependency and is loaded from your project.
Setup
tsconfig.json:
{
"vueCompilerOptions": {
"extensions": [".vue", ".pcss"],
"strictCssModules": true,
"resolveStyleImports": true,
"plugins": [
{
"name": "@flarian/language-plugin-css-modules",
"localsConvention": "camelCaseOnly",
},
],
},
}postcss.config.cjs, shared with Vite:
module.exports = {
plugins: {
'postcss-nested': {},
},
};Plugins are loaded by name from your project, so the config needs no require, and @typescript-eslint/no-require-imports has nothing to complain about.
An array of plugin instances works as well.
Vite picks this file up on its own, and the plugin reads the same file. Types and the build use one list of PostCSS plugins, so they cannot drift apart.
Options
| Option | Default | Meaning |
| ------------------ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| localsConvention | names as they are | Same values as css.modules.localsConvention in Vite: camelCase, camelCaseOnly, dashes, dashesOnly. Must match the build, or types and runtime disagree. |
| moduleSuffix | .module.pcss | Which stylesheets are CSS modules. |
| extensions | [".pcss"] | Stylesheet extensions the plugin owns. Every entry must also be listed in vueCompilerOptions.extensions. |
| postcssConfig | postcss.config.cjs | Config file name, searched upward from the stylesheet. |
Name conversion mirrors postcss-modules exactly, including lodash.camelcase for the camelCase modes.
Limitations
- Only synchronous PostCSS plugins affect types, because the language service is synchronous. With an asynchronous plugin the stylesheet is parsed without plugins.
- A function as
localsConventioncannot be written intsconfig.json. :global(...)classes are not module keys, same as inpostcss-modules.
How it works
The plugin registers two instances.
The first one runs before the built-in ones (order: -1) and claims every stylesheet extension.
Without it the built-in .vue handler takes the file, parses CSS as an SFC, and the import types as an empty component.
The second one runs after the built-in code generation (order: 1) and replaces the generated script with a module that declares the class names.
Each key is mapped back to its class in the stylesheet, so navigation lands on the right line.
