intl-watcher
Version:
Automated translation key extraction and dictionary management plugin for Next.js
157 lines (156 loc) • 6.25 kB
JavaScript
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
};