Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/codegen-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@css-modules-kit/codegen': minor
---

feat(codegen): add `--cache` option to skip emitting unchanged files
1 change: 1 addition & 0 deletions packages/codegen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Options:
--clean Remove the output directory before generating files. [default: false]
--watch, -w Watch for changes and regenerate files. [default: false]
--preserveWatchOutput Disable wiping the console in watch mode. [default: false]
--cache Only emit files that have changed since the last run. [default: false]
```

## Configuration
Expand Down
73 changes: 73 additions & 0 deletions packages/codegen/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { createHash } from 'node:crypto';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from '@css-modules-kit/core';
import type { CMKConfig } from '@css-modules-kit/core';
import packageJson from '../package.json' with { type: 'json' };

interface CacheData {
version: string;
configHash: string;
files: Record<string, string>;
}

function computeConfigHash(config: CMKConfig): string {
return createHash('sha256').update(JSON.stringify(config)).digest('hex');
}

function computeContentHash(text: string): string {
return createHash('sha256').update(text).digest('hex');
}

export class Cache {
readonly filePath: string;
private readonly configHash: string;
private readonly basePath: string;
// Use a null-prototype object so that keys like `__proto__` are stored as own properties
// without interfering with the prototype chain.
private files: Record<string, string> = Object.create(null);

constructor(config: CMKConfig) {
this.filePath = join(config.dtsOutDir, '.cache');
this.configHash = computeConfigHash(config);
this.basePath = config.basePath;
}

async load(): Promise<void> {
let text: string;
try {
text = await readFile(this.filePath, 'utf-8');
} catch {
return;
}
let data: CacheData;
try {
data = JSON.parse(text);
} catch {
return;
}
if (data.version !== packageJson.version || data.configHash !== this.configHash) {
return;
}
this.files = Object.assign(Object.create(null), data.files);
}

isHit(fileName: string, text: string): boolean {
const relPath = relative(this.basePath, fileName);
return this.files[relPath] === computeContentHash(text);
}

record(fileName: string, text: string): void {
const relPath = relative(this.basePath, fileName);
this.files[relPath] = computeContentHash(text);
}

async save(): Promise<void> {
const data: CacheData = {
version: packageJson.version,
configHash: this.configHash,
files: this.files,
};
await mkdir(dirname(this.filePath), { recursive: true });
await writeFile(this.filePath, JSON.stringify(data));
}
}
5 changes: 5 additions & 0 deletions packages/codegen/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('parseCLIArgs', () => {
clean: false,
watch: false,
preserveWatchOutput: false,
cache: false,
});
});
it('should parse --help option', () => {
Expand Down Expand Up @@ -52,6 +53,10 @@ describe('parseCLIArgs', () => {
expect(parseCLIArgs(['--preserveWatchOutput'], cwd).preserveWatchOutput).toBe(true);
expect(parseCLIArgs(['--no-preserveWatchOutput'], cwd).preserveWatchOutput).toBe(false);
});
it('should parse --cache option', () => {
expect(parseCLIArgs(['--cache'], cwd).cache).toBe(true);
expect(parseCLIArgs(['--no-cache'], cwd).cache).toBe(false);
});
it('should throw ParseCLIArgsError for invalid options', () => {
expect(() => parseCLIArgs(['--invalid-option'], cwd)).toThrow(ParseCLIArgsError);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/codegen/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Options:
--clean Remove the output directory before generating files. [default: false]
--watch, -w Watch for changes and regenerate files. [default: false]
--preserveWatchOutput Disable wiping the console in watch mode. [default: false]
--cache Only emit files that have changed since the last run. [default: false]
`;

export function printHelpText(): void {
Expand All @@ -33,6 +34,7 @@ export interface ParsedArgs {
clean: boolean;
watch: boolean;
preserveWatchOutput: boolean;
cache: boolean;
}

/**
Expand All @@ -51,6 +53,7 @@ export function parseCLIArgs(args: string[], cwd: string): ParsedArgs {
clean: { type: 'boolean', default: false },
watch: { type: 'boolean', short: 'w', default: false },
preserveWatchOutput: { type: 'boolean', default: false },
cache: { type: 'boolean', default: false },
},
allowNegative: true,
});
Expand All @@ -62,6 +65,7 @@ export function parseCLIArgs(args: string[], cwd: string): ParsedArgs {
clean: values.clean,
watch: values.watch,
preserveWatchOutput: values.preserveWatchOutput,
cache: values.cache,
};
} catch (cause) {
throw new ParseCLIArgsError(cause);
Expand Down
7 changes: 5 additions & 2 deletions packages/codegen/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
readConfigFile,
} from '@css-modules-kit/core';
import ts from 'typescript';
import type { Cache } from './cache.js';
import { writeDtsFile } from './dts-writer.js';
import { ReadCSSModuleFileError } from './error.js';

Expand Down Expand Up @@ -48,7 +49,7 @@ export interface Project {
* Emit .d.ts files for all project files.
* @throws {WriteDtsFileError}
*/
emitDtsFiles(): Promise<void>;
emitDtsFiles(cache?: Cache): Promise<void>;
}

/**
Expand Down Expand Up @@ -201,10 +202,11 @@ export function createProject(args: ProjectArgs): Project {
/**
* @throws {WriteDtsFileError}
*/
async function emitDtsFiles(): Promise<void> {
async function emitDtsFiles(cache?: Cache): Promise<void> {
const promises: Promise<void>[] = [];
for (const cssModule of cssModuleMap.values()) {
if (emittedSet.has(cssModule.fileName)) continue;
if (cache?.isHit(cssModule.fileName, cssModule.text)) continue;
const dts = generateDts(cssModule, { ...config, forTsPlugin: false });
promises.push(
writeDtsFile(dts.text, cssModule.fileName, {
Expand All @@ -213,6 +215,7 @@ export function createProject(args: ProjectArgs): Project {
arbitraryExtensions: config.arbitraryExtensions,
}).then(() => {
emittedSet.add(cssModule.fileName);
cache?.record(cssModule.fileName, cssModule.text);
}),
);
}
Expand Down
95 changes: 94 additions & 1 deletion packages/codegen/src/runner.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { access, rm, writeFile } from 'node:fs/promises';
import { access, readFile, rm, writeFile } from 'node:fs/promises';
import { platform } from 'node:process';
import dedent from 'dedent';
import { afterEach, describe, expect, test, vi } from 'vite-plus/test';
Expand Down Expand Up @@ -108,6 +108,99 @@ describe('runCMK', () => {
});
await expect(runCMK(fakeParsedArgs({ project: iff.rootDir }), createLoggerSpy())).rejects.toThrow(CMKDisabledError);
});
describe('--cache', () => {
test('writes a cache file under dtsOutDir', async () => {
const iff = await createIFF({
'tsconfig.json': '{ "cmkOptions": { "enabled": true, "dtsOutDir": "generated" } }',
'src/a.module.css': '.a_1 { color: red; }',
});
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), createLoggerSpy());
const cacheText = await readFile(iff.join('generated/.cache'), 'utf-8');
const cache = JSON.parse(cacheText);
expect(cache).toMatchObject({
version: expect.any(String),
configHash: expect.any(String),
files: { 'src/a.module.css': expect.any(String) },
});
});
test('skips emitting an unchanged file on the second run', async () => {
const iff = await createIFF({
'tsconfig.json': '{ "cmkOptions": { "enabled": true, "dtsOutDir": "generated" } }',
'src/a.module.css': '.a_1 { color: red; }',
});
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), createLoggerSpy());
await writeFile(iff.join('generated/src/a.module.css.d.ts'), 'STALE');
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), createLoggerSpy());
expect(await readFile(iff.join('generated/src/a.module.css.d.ts'), 'utf-8')).toBe('STALE');
});
test('reemits only the file whose content changed', async () => {
const iff = await createIFF({
'tsconfig.json': '{ "cmkOptions": { "enabled": true, "dtsOutDir": "generated" } }',
'src/a.module.css': '.a_1 { color: red; }',
'src/b.module.css': '.b_1 { color: blue; }',
});
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), createLoggerSpy());
await writeFile(iff.join('generated/src/a.module.css.d.ts'), 'STALE');
await writeFile(iff.join('generated/src/b.module.css.d.ts'), 'STALE');
await writeFile(iff.join('src/a.module.css'), '.a_2 { color: green; }');
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), createLoggerSpy());
expect(await readFile(iff.join('generated/src/a.module.css.d.ts'), 'utf-8')).not.toBe('STALE');
expect(await readFile(iff.join('generated/src/b.module.css.d.ts'), 'utf-8')).toBe('STALE');
});
test('invalidates the entire cache when cmkOptions change', async () => {
const iff = await createIFF({
'tsconfig.json': '{ "cmkOptions": { "enabled": true, "dtsOutDir": "generated" } }',
'src/a.module.css': '.a_1 { color: red; }',
});
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), createLoggerSpy());
await writeFile(iff.join('generated/src/a.module.css.d.ts'), 'STALE');
await writeFile(
iff.join('tsconfig.json'),
'{ "cmkOptions": { "enabled": true, "dtsOutDir": "generated", "namedExports": true } }',
);
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), createLoggerSpy());
expect(await readFile(iff.join('generated/src/a.module.css.d.ts'), 'utf-8')).not.toBe('STALE');
});
test('invalidates the entire cache when the cache version changes', async () => {
const iff = await createIFF({
'tsconfig.json': '{ "cmkOptions": { "enabled": true, "dtsOutDir": "generated" } }',
'src/a.module.css': '.a_1 { color: red; }',
});
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), createLoggerSpy());
await writeFile(iff.join('generated/src/a.module.css.d.ts'), 'STALE');
const cacheText = await readFile(iff.join('generated/.cache'), 'utf-8');
const cache = JSON.parse(cacheText);
await writeFile(iff.join('generated/.cache'), JSON.stringify({ ...cache, version: '0.0.0-old' }));
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), createLoggerSpy());
expect(await readFile(iff.join('generated/src/a.module.css.d.ts'), 'utf-8')).not.toBe('STALE');
});
test('emits all files when clean is used together', async () => {
const iff = await createIFF({
'tsconfig.json': '{ "cmkOptions": { "enabled": true, "dtsOutDir": "generated" } }',
'src/a.module.css': '.a_1 { color: red; }',
});
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), createLoggerSpy());
await writeFile(iff.join('generated/src/a.module.css.d.ts'), 'STALE');
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true, clean: true }), createLoggerSpy());
expect(await readFile(iff.join('generated/src/a.module.css.d.ts'), 'utf-8')).not.toBe('STALE');
});
test('reports a diagnostic for an unchanged file when its dependency changes', async () => {
const iff = await createIFF({
'tsconfig.json': '{ "cmkOptions": { "enabled": true, "dtsOutDir": "generated" } }',
'src/a.module.css': `@value b_1 from './b.module.css';\n.a_1 { color: red; }`,
'src/b.module.css': '.b_1 { color: blue; }',
});
const loggerSpy1 = createLoggerSpy();
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), loggerSpy1);
expect(loggerSpy1.logDiagnostics).not.toHaveBeenCalled();
await writeFile(iff.join('generated/src/a.module.css.d.ts'), 'STALE');
await writeFile(iff.join('src/b.module.css'), '.b_2 { color: blue; }');
const loggerSpy2 = createLoggerSpy();
await runCMK(fakeParsedArgs({ project: iff.rootDir, cache: true }), loggerSpy2);
expect(loggerSpy2.logDiagnostics).toHaveBeenCalledTimes(1);
expect(await readFile(iff.join('generated/src/a.module.css.d.ts'), 'utf-8')).toBe('STALE');
});
});
});

describe('runCMKInWatchMode', () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/codegen/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Stats } from 'node:fs';
import { rm } from 'node:fs/promises';
import { basename } from '@css-modules-kit/core';
import chokidar, { type FSWatcher } from 'chokidar';
import { Cache } from './cache.js';
import { CMKDisabledError } from './error.js';
import type { Logger } from './logger/logger.js';
import { createProject, type Project } from './project.js';
Expand All @@ -16,6 +17,7 @@ interface RunnerArgs {
project: string;
clean: boolean;
preserveWatchOutput: boolean;
cache: boolean;
}

export interface Watcher {
Expand All @@ -40,7 +42,13 @@ export async function runCMK(args: RunnerArgs, logger: Logger): Promise<boolean>
if (args.clean) {
await rm(project.config.dtsOutDir, { recursive: true, force: true });
}
await project.emitDtsFiles();
let cache: Cache | undefined;
if (args.cache) {
cache = new Cache(project.config);
await cache.load();
}
await project.emitDtsFiles(cache);
await cache?.save();
const diagnostics = project.getDiagnostics();
if (diagnostics.length > 0) {
logger.logDiagnostics(diagnostics);
Expand Down
1 change: 1 addition & 0 deletions packages/codegen/src/test/faker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function fakeParsedArgs(args?: Partial<ParsedArgs>): ParsedArgs {
clean: false,
watch: false,
preserveWatchOutput: false,
cache: false,
...args,
};
}