vite-plugin-css-module-types 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts ADDED
@@ -0,0 +1,287 @@
1
+ import type { Plugin } from "vite";
2
+ import { mkdir, readFile, writeFile, readdir, stat } from "node:fs/promises";
3
+ import { mkdirSync } from "node:fs";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ normalizeFolderList,
8
+ shouldProcessFile,
9
+ patchViteModuleCode,
10
+ evaluateModuleExports,
11
+ buildClassNameIndex,
12
+ buildOriginalClassLineMap,
13
+ extractCssRuleBodies,
14
+ resolveClassEntries,
15
+ extractCssComments,
16
+ generateDtsContent,
17
+ generateDeclarationMap,
18
+ getInlineLineMappings,
19
+ } from "./lib/index.ts";
20
+ import { toCamelCase } from "./lib/camel-case.ts";
21
+ import type { VitePluginCssModuleTypesOptions, DtsGenerationOptions } from "./lib/index.ts";
22
+
23
+ async function findFiles(dir: string, suffix: string): Promise<string[]> {
24
+ const results: string[] = [];
25
+ let entries;
26
+ try {
27
+ entries = await readdir(dir, { withFileTypes: true });
28
+ } catch {
29
+ return results;
30
+ }
31
+ for (const entry of entries) {
32
+ const full = path.join(dir, entry.name);
33
+ if (entry.isDirectory()) {
34
+ results.push(...(await findFiles(full, suffix)));
35
+ } else if (entry.name.endsWith(suffix)) {
36
+ results.push(full);
37
+ }
38
+ }
39
+ return results;
40
+ }
41
+
42
+ /**
43
+ * Builds a class mapping from raw CSS source, simulating Vite's
44
+ * `localsConvention: 'camelCase'` behavior.
45
+ */
46
+ function buildClassMappingFromSource(cssSource: string): Record<string, string> {
47
+ const mapping: Record<string, string> = {};
48
+ const classRegex = /\.([a-zA-Z_][\w-]*)/g;
49
+
50
+ for (const match of cssSource.matchAll(classRegex)) {
51
+ const name = match[1];
52
+ if (!name || name in mapping) continue;
53
+ mapping[name] = name;
54
+ const camel = toCamelCase(name);
55
+ if (camel !== name && !(camel in mapping)) {
56
+ mapping[camel] = name;
57
+ }
58
+ }
59
+ return mapping;
60
+ }
61
+
62
+ /** Creates a Vite dev-server plugin that generates `.d.ts` files for CSS Modules. */
63
+ const DEFAULT_EXCLUDE_FOLDERS = ["node_modules", ".git", "dist", "build"];
64
+ function viteCssModuleTypesPlugin(
65
+ options: VitePluginCssModuleTypesOptions = {
66
+ dtsOutputDir: ".css-module-types",
67
+ },
68
+ ): Plugin {
69
+ let rootDir = "";
70
+ let cssSourceMapEnabled = false;
71
+ const includeFolders = normalizeFolderList(options.include);
72
+ const excludeFolders = normalizeFolderList(
73
+ options.exclude ?? DEFAULT_EXCLUDE_FOLDERS,
74
+ );
75
+
76
+ let dtsGenOptions: DtsGenerationOptions;
77
+
78
+ const createdDirs = new Set<string>();
79
+ const contentCache = new Map<string, string>();
80
+
81
+ async function ensureDir(dirPath: string): Promise<void> {
82
+ if (createdDirs.has(dirPath)) return;
83
+ await mkdir(dirPath, { recursive: true });
84
+ createdDirs.add(dirPath);
85
+ }
86
+
87
+ /**
88
+ * Generates a `.d.ts` from raw CSS source (no Vite transform pipeline).
89
+ * Used by initial scan. Does not produce declaration maps.
90
+ */
91
+ async function generateDtsFromSource(absolutePath: string, force = false): Promise<void> {
92
+ const relativePath = path.relative(rootDir, absolutePath);
93
+
94
+ const dtsFileName = options.allowArbitraryExtensions
95
+ ? relativePath.replace(/\.css$/, ".d.css.ts")
96
+ : `${relativePath}.d.ts`;
97
+ const dtsFilePath = path.join(rootDir, options.dtsOutputDir, dtsFileName);
98
+
99
+ if (!force) {
100
+ try {
101
+ await stat(dtsFilePath);
102
+ return; // Already exists
103
+ } catch {
104
+ // Proceed
105
+ }
106
+ }
107
+
108
+ const source = await readFile(absolutePath, "utf-8");
109
+ const classMapping = buildClassMappingFromSource(source);
110
+ const comments = extractCssComments(source);
111
+ const originalClassLines = buildOriginalClassLineMap(source);
112
+ const cssRuleBodies = extractCssRuleBodies(source);
113
+
114
+ const entries = resolveClassEntries(classMapping, null, comments, originalClassLines, cssRuleBodies);
115
+ const fileName = path.basename(absolutePath);
116
+ const { content: dtsBody } = generateDtsContent(entries, fileName, absolutePath, dtsGenOptions);
117
+
118
+ const outputDir = path.join(rootDir, options.dtsOutputDir, path.dirname(relativePath));
119
+ await ensureDir(outputDir);
120
+ await writeFile(dtsFilePath, dtsBody);
121
+ }
122
+
123
+ /** Scans all `.module.css` files and generates initial `.d.ts` stubs. */
124
+ async function scanAllCssModules(): Promise<void> {
125
+ const scanRoots = includeFolders.length > 0
126
+ ? includeFolders.map((f) => path.join(rootDir, f))
127
+ : [rootDir];
128
+
129
+ const allFiles = (await Promise.all(
130
+ scanRoots.map((dir) => findFiles(dir, ".module.css")),
131
+ )).flat();
132
+
133
+ const eligible = allFiles.filter((f) => {
134
+ const rel = path.relative(rootDir, f);
135
+ return shouldProcessFile(rel, includeFolders, excludeFolders);
136
+ });
137
+
138
+ const results = await Promise.allSettled(
139
+ eligible.map((f) => generateDtsFromSource(f)),
140
+ );
141
+
142
+ let generated = 0;
143
+ for (const r of results) {
144
+ if (r.status === "fulfilled") generated++;
145
+ }
146
+
147
+ if (generated > 0) {
148
+ console.log(`[css-module-dts] Generated ${generated} declaration files on startup.`);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Core `.d.ts` generation from Vite's transformed CSS module code.
154
+ * Called from both `transform` and `handleHotUpdate`.
155
+ */
156
+ async function generateDtsFromTransform(code: string, id: string): Promise<void> {
157
+ const relativePath = path.relative(rootDir, id);
158
+
159
+ const patchedCode = patchViteModuleCode(code);
160
+ const { classMapping, cssText } = await evaluateModuleExports(patchedCode);
161
+
162
+ const lineMappings = cssSourceMapEnabled ? await getInlineLineMappings(cssText) : null;
163
+ const classIndex = lineMappings ? buildClassNameIndex(lineMappings) : null;
164
+
165
+ const source = await readFile(id, "utf-8");
166
+ const comments = extractCssComments(source);
167
+ const originalClassLines = buildOriginalClassLineMap(source);
168
+ const cssRuleBodies = extractCssRuleBodies(source);
169
+
170
+ const entries = resolveClassEntries(classMapping, classIndex, comments, originalClassLines, cssRuleBodies);
171
+
172
+ const fileName = path.basename(id);
173
+ const { content: dtsBody, lineMappings: dtsLineMappings } = generateDtsContent(
174
+ entries, fileName, id, dtsGenOptions,
175
+ );
176
+ const outputDir = path.join(rootDir, options.dtsOutputDir, path.dirname(relativePath));
177
+ await ensureDir(outputDir);
178
+
179
+ const dtsFileName = options.allowArbitraryExtensions
180
+ ? relativePath.replace(/\.css$/, ".d.css.ts")
181
+ : `${relativePath}.d.ts`;
182
+ const dtsFilePath = path.join(rootDir, options.dtsOutputDir, dtsFileName);
183
+
184
+ const useDeclarationMap =
185
+ (options.declarationMap ?? true) && cssSourceMapEnabled && dtsLineMappings.length > 0;
186
+
187
+ const dtsBaseName = path.basename(dtsFileName);
188
+ const mapFileName = `${dtsBaseName}.map`;
189
+
190
+ const dtsContent = useDeclarationMap
191
+ ? `${dtsBody}//# sourceMappingURL=${mapFileName}\n`
192
+ : dtsBody;
193
+
194
+ let mapContent: string | undefined;
195
+ if (useDeclarationMap) {
196
+ const cssRelativePath = path.relative(outputDir, id);
197
+ const totalDtsLines = dtsContent.split("\n").length;
198
+ mapContent = generateDeclarationMap(dtsBaseName, cssRelativePath, dtsLineMappings, totalDtsLines);
199
+ }
200
+
201
+ const mapFilePath = useDeclarationMap ? path.join(outputDir, mapFileName) : undefined;
202
+
203
+ // Dirty-check: skip write when both .d.ts and .d.ts.map are identical.
204
+ try {
205
+ const existingDts = await readFile(dtsFilePath, "utf-8");
206
+ if (existingDts === dtsContent) {
207
+ if (!mapFilePath || !mapContent) return;
208
+ try {
209
+ const existingMap = await readFile(mapFilePath, "utf-8");
210
+ if (existingMap === mapContent) return;
211
+ } catch {
212
+ // Map file doesn't exist yet
213
+ }
214
+ }
215
+ } catch {
216
+ // .d.ts doesn't exist yet
217
+ }
218
+
219
+ await writeFile(dtsFilePath, dtsContent);
220
+
221
+ if (mapFilePath && mapContent) {
222
+ await writeFile(mapFilePath, mapContent);
223
+ }
224
+ }
225
+
226
+ return {
227
+ name: "vite-plugin-css-module-types",
228
+ apply: "serve",
229
+ enforce: "post",
230
+
231
+ configResolved(config) {
232
+ rootDir = config.root;
233
+ cssSourceMapEnabled = config.css.devSourcemap;
234
+ mkdirSync(path.join(rootDir, options.dtsOutputDir), { recursive: true });
235
+ dtsGenOptions = { namedExports: options.namedExports };
236
+ },
237
+
238
+ configureServer() {
239
+ scanAllCssModules().catch((err) => {
240
+ console.warn("[css-module-dts] Initial scan failed:", err);
241
+ });
242
+ },
243
+
244
+ async transform(code, id) {
245
+ if (!id.endsWith(".module.css")) return null;
246
+
247
+ const relativePath = path.relative(rootDir, id);
248
+ if (!shouldProcessFile(relativePath, includeFolders, excludeFolders)) return null;
249
+
250
+ if (contentCache.get(id) === code) return null;
251
+ contentCache.set(id, code);
252
+
253
+ try {
254
+ await generateDtsFromTransform(code, id);
255
+ } catch (error) {
256
+ console.warn(
257
+ `[css-module-dts] Failed to generate types for ${relativePath}:`,
258
+ error instanceof Error ? error.message : error,
259
+ );
260
+ }
261
+
262
+ return null;
263
+ },
264
+
265
+ handleHotUpdate({ file }) {
266
+ if (!file.endsWith(".module.css")) return;
267
+
268
+ const relativePath = path.relative(rootDir, file);
269
+ if (!shouldProcessFile(relativePath, includeFolders, excludeFolders)) return;
270
+
271
+ // Clear content cache so the next transform processes the file.
272
+ contentCache.delete(file);
273
+
274
+ // Re-generate .d.ts from raw source immediately (without waiting for
275
+ // Vite's transform pipeline). This ensures types are up-to-date even
276
+ // if the module isn't re-requested by the browser right away.
277
+ generateDtsFromSource(file, true).catch((err) => {
278
+ console.warn(`[css-module-dts] HMR re-generation failed for ${relativePath}:`, err);
279
+ });
280
+
281
+ // Return undefined to let Vite handle HMR normally.
282
+ return;
283
+ },
284
+ };
285
+ }
286
+
287
+ export default viteCssModuleTypesPlugin;
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { toCamelCase } from "../lib/camel-case.ts";
3
+
4
+ describe("toCamelCase", () => {
5
+ it("converts kebab-case to camelCase", () => {
6
+ expect(toCamelCase("my-class")).toBe("myClass");
7
+ });
8
+
9
+ it("converts multiple dashes", () => {
10
+ expect(toCamelCase("my-cool-long-class")).toBe("myCoolLongClass");
11
+ });
12
+
13
+ it("returns the same string when no dashes are present", () => {
14
+ expect(toCamelCase("button")).toBe("button");
15
+ });
16
+
17
+ it("handles leading single character segments", () => {
18
+ expect(toCamelCase("a-b-c")).toBe("aBC");
19
+ });
20
+
21
+ it("preserves underscores", () => {
22
+ expect(toCamelCase("my_class")).toBe("my_class");
23
+ });
24
+
25
+ it("handles mixed dashes and underscores", () => {
26
+ expect(toCamelCase("my_class-name")).toBe("my_className");
27
+ });
28
+
29
+ it("handles digits after dash", () => {
30
+ expect(toCamelCase("section-2")).toBe("section2");
31
+ });
32
+
33
+ it("returns empty string for empty input", () => {
34
+ expect(toCamelCase("")).toBe("");
35
+ });
36
+ });