import { resolve, relative } from 'path'; import * as process from 'process'; import { createCache } from '@file-cache/core'; import { createNpmPackageKey } from '@file-cache/npm'; import { Mutex } from 'async-mutex'; import chalk from 'chalk'; import * as chokidar from 'chokidar'; import { glob } from 'glob'; import { DEFAULT_ARBITRARY_EXTENSIONS } from './config.js'; import { isGeneratedFilesExist, emitGeneratedFiles } from './emitter/index.js'; import { Locator } from './locator/index.js'; import { Logger } from './logger.js'; import type { Resolver } from './resolver/index.js'; import { createDefaultResolver } from './resolver/index.js'; import { createDefaultTransformer, type Transformer } from './transformer/index.js'; import { getInstalledPeerDependencies, isMatchByGlob } from './util.js'; export type Watcher = { close: () => Promise; }; export type LocalsConvention = 'camelCase' | 'camelCaseOnly' | 'dashes' | 'dashesOnly' | undefined; export interface RunnerOptions { pattern: string; watch?: boolean | undefined; /** * Style of exported class names. * @default undefined */ localsConvention?: LocalsConvention | undefined; declarationMap?: boolean | undefined; transformer?: Transformer | undefined; resolver?: Resolver | undefined; /** * The option compatible with sass's `--load-path`. It is an array of relative or absolute paths. * @example ['src/styles'] * @example ['/home/user/repository/src/styles'] */ sassLoadPaths?: string[] | undefined; /** * The option compatible with less's `--include-path`. It is an array of relative or absolute paths. * @example ['src/styles'] * @example ['/home/user/repository/src/styles'] */ lessIncludePaths?: string[] | undefined; /** * The option compatible with webpack's `resolve.alias`. It is an object consisting of a pair of alias names and relative or absolute paths. * @example { style: 'src/styles', '@': 'src' } * @example { style: '/home/user/repository/src/styles', '@': '/home/user/repository/src' } */ webpackResolveAlias?: Record | undefined; /** * The option compatible with postcss's `--config`. It is a relative or absolute path. * @example '.' * @example 'postcss.config.js' * @example '/home/user/repository/src' */ postcssConfig?: string | undefined; /** * Generate `.d.css.ts` instead of `.css.d.ts`. * @default false */ arbitraryExtensions?: boolean | undefined; /** * Only generate .d.ts and .d.ts.map for changed files. * @default true */ cache?: boolean | undefined; /** * Strategy for the cache to use for detecting changed files. * @default 'content' */ cacheStrategy?: 'content' | 'metadata' | undefined; /** * What level of logs to report. * @default 'info' */ logLevel?: 'debug' | 'info' | 'silent' | undefined; /** Working directory path. */ cwd?: string | undefined; /** Output directory for generated files. */ outDir?: string | undefined; } type OverrideProp = Omit & { [P in K]: V }; /** * Run typed-css-module. * @param options Runner options. * @returns Returns `Promise` if `options.watch` is `true`, `Promise` if `false`. */ export async function run(options: OverrideProp): Promise; export async function run(options: RunnerOptions): Promise; export async function run(options: RunnerOptions): Promise { const lock = new Mutex(); const logger = new Logger(options.logLevel ?? 'info'); const cwd = options.cwd ?? process.cwd(); const resolver = options.resolver ?? createDefaultResolver({ cwd, sassLoadPaths: options.sassLoadPaths, lessIncludePaths: options.lessIncludePaths, webpackResolveAlias: options.webpackResolveAlias, }); const transformer = options.transformer ?? createDefaultTransformer({ cwd, postcssConfig: options.postcssConfig }); const installedPeerDependencies = getInstalledPeerDependencies(); const cache = await createCache({ name: 'happy-css-modules', mode: options.cacheStrategy ?? 'content', keys: [ () => createNpmPackageKey(['happy-css-modules', ...installedPeerDependencies]), () => { return JSON.stringify(options); }, ], noCache: !(options.cache ?? true), }); const locator = new Locator({ transformer, resolver }); const isExternalFile = (filePath: string) => { return !isMatchByGlob(filePath, options.pattern, { cwd }); }; async function processFile(filePath: string) { async function isChangedFile(filePath: string) { const result = await cache.getAndUpdateCache(filePath); // eslint-disable-next-line @typescript-eslint/no-throw-literal if (result.error) throw result.error; return result.changed; } // Locator#load cannot be called concurrently. Therefore, it takes a lock and waits. await lock.acquire(); try { const _isGeneratedFilesExist = await isGeneratedFilesExist( filePath, options.declarationMap, options.arbitraryExtensions ?? DEFAULT_ARBITRARY_EXTENSIONS, options.outDir, cwd, ); const _isChangedFile = await isChangedFile(filePath); // Generate .d.ts and .d.ts.map only when the file has been updated. // However, if .d.ts or .d.ts.map has not yet been generated, always generate. if (_isGeneratedFilesExist && !_isChangedFile) { logger.debug(chalk.gray(`${relative(cwd, filePath)} (skipped)`)); return; } const result = await locator.load(filePath); await emitGeneratedFiles({ filePath, tokens: result.tokens, emitDeclarationMap: options.declarationMap, dtsFormatOptions: { localsConvention: options.localsConvention, arbitraryExtensions: options.arbitraryExtensions, }, isExternalFile, outDir: options.outDir, cwd, }); logger.info(chalk.green(`${relative(cwd, filePath)} (generated)`)); await cache.reconcile(); // Update cache for the file } finally { lock.release(); } } async function processAllFiles() { const filePaths = (await glob(options.pattern, { dot: true, cwd })) // convert relative path to absolute path .map((file) => resolve(cwd, file)); const errors: unknown[] = []; for (const filePath of filePaths) { // eslint-disable-next-line no-await-in-loop await processFile(filePath).catch((e) => errors.push(e)); } if (errors.length > 0) { throw new AggregateError(errors, 'Failed to process files'); } } if (!options.watch) { logger.info(`Generate .d.ts for ${options.pattern}...`); await processAllFiles(); // Write cache state to file for persistence } else { // First, watch files. logger.info(`Watch ${options.pattern}...`); const watcher = chokidar.watch([options.pattern.replace(/\\/gu, '/')], { cwd }); watcher.on('all', (eventName, relativeFilePath) => { const filePath = resolve(cwd, relativeFilePath); // There is a bug in chokidar that matches symlinks that do not match the pattern. // ref: https://github.com/paulmillr/chokidar/issues/967 if (isExternalFile(filePath)) return; if (eventName !== 'add' && eventName !== 'change') return; processFile(filePath).catch((e) => { logger.error(e); // TODO: Emit a error by `Watcher#onerror` }); }); return { close: async () => watcher.close() }; } }