diff --git a/.changeset/codegen-cache.md b/.changeset/codegen-cache.md new file mode 100644 index 00000000..630ae4bd --- /dev/null +++ b/.changeset/codegen-cache.md @@ -0,0 +1,5 @@ +--- +'@css-modules-kit/codegen': minor +--- + +feat(codegen): add `--cache` option to skip emitting unchanged files diff --git a/packages/codegen/README.md b/packages/codegen/README.md index e5dc838b..df032c4e 100644 --- a/packages/codegen/README.md +++ b/packages/codegen/README.md @@ -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 diff --git a/packages/codegen/src/cache.ts b/packages/codegen/src/cache.ts new file mode 100644 index 00000000..bb4bd496 --- /dev/null +++ b/packages/codegen/src/cache.ts @@ -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; +} + +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 = Object.create(null); + + constructor(config: CMKConfig) { + this.filePath = join(config.dtsOutDir, '.cache'); + this.configHash = computeConfigHash(config); + this.basePath = config.basePath; + } + + async load(): Promise { + 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 { + 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)); + } +} diff --git a/packages/codegen/src/cli.test.ts b/packages/codegen/src/cli.test.ts index 045b8481..8a457607 100644 --- a/packages/codegen/src/cli.test.ts +++ b/packages/codegen/src/cli.test.ts @@ -16,6 +16,7 @@ describe('parseCLIArgs', () => { clean: false, watch: false, preserveWatchOutput: false, + cache: false, }); }); it('should parse --help option', () => { @@ -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); }); diff --git a/packages/codegen/src/cli.ts b/packages/codegen/src/cli.ts index 2688946a..0795e626 100644 --- a/packages/codegen/src/cli.ts +++ b/packages/codegen/src/cli.ts @@ -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 { @@ -33,6 +34,7 @@ export interface ParsedArgs { clean: boolean; watch: boolean; preserveWatchOutput: boolean; + cache: boolean; } /** @@ -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, }); @@ -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); diff --git a/packages/codegen/src/project.ts b/packages/codegen/src/project.ts index 275d6706..4375a628 100644 --- a/packages/codegen/src/project.ts +++ b/packages/codegen/src/project.ts @@ -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'; @@ -48,7 +49,7 @@ export interface Project { * Emit .d.ts files for all project files. * @throws {WriteDtsFileError} */ - emitDtsFiles(): Promise; + emitDtsFiles(cache?: Cache): Promise; } /** @@ -201,10 +202,11 @@ export function createProject(args: ProjectArgs): Project { /** * @throws {WriteDtsFileError} */ - async function emitDtsFiles(): Promise { + async function emitDtsFiles(cache?: Cache): Promise { const promises: Promise[] = []; 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, { @@ -213,6 +215,7 @@ export function createProject(args: ProjectArgs): Project { arbitraryExtensions: config.arbitraryExtensions, }).then(() => { emittedSet.add(cssModule.fileName); + cache?.record(cssModule.fileName, cssModule.text); }), ); } diff --git a/packages/codegen/src/runner.test.ts b/packages/codegen/src/runner.test.ts index f034f574..c4581f5c 100644 --- a/packages/codegen/src/runner.test.ts +++ b/packages/codegen/src/runner.test.ts @@ -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'; @@ -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', () => { diff --git a/packages/codegen/src/runner.ts b/packages/codegen/src/runner.ts index 8789f24f..a4cc9eab 100644 --- a/packages/codegen/src/runner.ts +++ b/packages/codegen/src/runner.ts @@ -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'; @@ -16,6 +17,7 @@ interface RunnerArgs { project: string; clean: boolean; preserveWatchOutput: boolean; + cache: boolean; } export interface Watcher { @@ -40,7 +42,13 @@ export async function runCMK(args: RunnerArgs, logger: Logger): Promise 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); diff --git a/packages/codegen/src/test/faker.ts b/packages/codegen/src/test/faker.ts index f115d457..dc2ec620 100644 --- a/packages/codegen/src/test/faker.ts +++ b/packages/codegen/src/test/faker.ts @@ -9,6 +9,7 @@ export function fakeParsedArgs(args?: Partial): ParsedArgs { clean: false, watch: false, preserveWatchOutput: false, + cache: false, ...args, }; }