UNPKG

intl-watcher

Version:

Automated translation key extraction and dictionary management plugin for Next.js

157 lines (156 loc) 6.25 kB
import path from "node:path"; import { setTimeout } from "node:timers"; import chokidar from "chokidar"; import debounce from "debounce"; import lodash from "lodash"; import properLockfile from "proper-lockfile"; import { Project } from "ts-morph"; import { NEXT_INTL_GET_TRANSLATIONS_FUNCTION, NEXT_INTL_USE_TRANSLATIONS_FUNCTION } from "./constants.js"; import { log } from "./logger.js"; import { extractTranslationKeysFromProject } from "./parser.js"; import { flattenDictionary, formatDuration, readDictionaryFile, unflattenDictionary, writeDictionaryFile } from "./utils.js"; function buildIntlWatcherOptions(options) { const partitioningOptions = options.applyPartitioning ? { applyPartitioning: true, partitioningOptions: { clientFunction: options.partitioningOptions?.clientFunction ?? NEXT_INTL_USE_TRANSLATIONS_FUNCTION, serverFunction: options.partitioningOptions?.serverFunction ?? NEXT_INTL_GET_TRANSLATIONS_FUNCTION } } : { applyPartitioning: false, translationFunctions: [NEXT_INTL_USE_TRANSLATIONS_FUNCTION, NEXT_INTL_GET_TRANSLATIONS_FUNCTION] }; const sourceDirectories = options.sourceDirectories ?? [options.sourceDirectory ?? "./src"]; return { dictionaryPaths: options.dictionaryPaths.map((dictionaryPath) => path.resolve(dictionaryPath)), scanDelay: options.scanDelay ?? 500, defaultValue: options.defaultValue ?? ((key) => `[NYT: ${key}]`), removeUnusedKeys: options.removeUnusedKeys ?? false, sourceDirectories, sourceDirectory: sourceDirectories?.[0] ?? options.sourceDirectory ?? "./src", tsConfigFilePath: options.tsConfigFilePath ?? "tsconfig.json", ...partitioningOptions }; } class IntlWatcher { constructor(_options, _project = new Project({ tsConfigFilePath: _options.tsConfigFilePath })) { this._options = _options; this._project = _project; } _isSelfTriggerGuardActive = false; _changedFiles = /* @__PURE__ */ new Set(); startWatching() { const debouncedScan = debounce(() => this.scanSourceFilesForTranslationKeys(), this._options.scanDelay); const watcher = chokidar.watch(this._options.sourceDirectories, { ignoreInitial: true }).on("all", (_event, filename) => { const absoluteFilename = path.resolve(filename); if (!this.shouldProcessFile(absoluteFilename) || this._options.dictionaryPaths.includes(absoluteFilename) && this._isSelfTriggerGuardActive) { return; } this._changedFiles.add(absoluteFilename); debouncedScan(); }); process.on("exit", async () => { await watcher.close(); }); } scanSourceFilesForTranslationKeys() { log.waiting("Scanning..."); const startTime = process.hrtime.bigint(); for (const filePath of this._changedFiles) { const sourceFile = this._project.getSourceFile(filePath) ?? this._project.addSourceFileAtPathIfExists(filePath); sourceFile?.refreshFromFileSystemSync(); } this._changedFiles.clear(); let skipLogging = false; const [clientTranslationKeys, serverTranslationKeys] = extractTranslationKeysFromProject( this._project, this._options ); for (const dictionaryPath of this._options.dictionaryPaths) { this.synchronizeDictionaryFile( dictionaryPath, clientTranslationKeys, serverTranslationKeys, skipLogging ); skipLogging = true; } const endTime = process.hrtime.bigint(); const delta = Number(endTime - startTime) / 1e6; log.success(`Finished in ${formatDuration(delta)}`); } synchronizeDictionaryFile(dictionaryPath, clientTranslationKeys, serverTranslationKeys, skipLogging) { const release = properLockfile.lockSync(dictionaryPath); try { const messages = flattenDictionary(readDictionaryFile(dictionaryPath)); const translationKeys = /* @__PURE__ */ new Set([...clientTranslationKeys, ...serverTranslationKeys]); for (const key in messages) { if (translationKeys.has(key)) { continue; } if (this._options.removeUnusedKeys) { delete messages[key]; if (!skipLogging) { log.success(`Removed unused i18n key \`${key}\``); } } else if (!skipLogging) { log.warn(`Unused i18n key \`${key}\``); } } for (const key of translationKeys) { if (messages[key] === void 0 && !skipLogging) { log.success(`Added new i18n key \`${key}\``); } messages[key] ??= this._options.defaultValue(key); } const flatMessages = lodash.pick(messages, Object.keys(messages).toSorted()); const updatedMessages = unflattenDictionary(flatMessages); this.enabledSelfTriggerGuard(); writeDictionaryFile(dictionaryPath, updatedMessages); if (this._options.applyPartitioning) { this.partitionTranslationKeys( flatMessages, clientTranslationKeys, serverTranslationKeys, dictionaryPath ); } } finally { release(); } } shouldProcessFile(filename) { const isTsFile = filename.endsWith(".ts") || filename.endsWith(".tsx"); const isDictionary = this._options.dictionaryPaths.includes(filename); const isDeclarationFile = filename.endsWith(".d.ts") || filename.endsWith(".d.json.ts"); return (isTsFile || isDictionary) && !isDeclarationFile; } partitionTranslationKeys(messages, clientKeys, serverKeys, dictionaryPath) { const clientDictionary = lodash.pick(messages, clientKeys); const serverDictionary = lodash.pick( messages, serverKeys.filter((key) => !clientKeys.includes(key)) ); const clientMessages = unflattenDictionary(clientDictionary); const serverMessages = unflattenDictionary(serverDictionary); const { dir, ext, name } = path.parse(dictionaryPath); writeDictionaryFile(path.join(dir, `${name}.client${ext}`), clientMessages); writeDictionaryFile(path.join(dir, `${name}.server${ext}`), serverMessages); } enabledSelfTriggerGuard() { this._isSelfTriggerGuardActive = true; setTimeout(() => { this._isSelfTriggerGuardActive = false; }, this._options.scanDelay); } } export { IntlWatcher, buildIntlWatcherOptions };