All files / scripts/locale manager.ts

67.92% Statements 36/53
58.33% Branches 7/12
73.33% Functions 11/15
66.66% Lines 32/48

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 1951x 1x 1x 1x 1x               168x           1x 1x 1x                 1x 1x                 8x 168x             1x 4x                     152x                   152x 152x 152x 116x                                                                                         8x 8x                                           8x 152x     8x 8x   3248x         61712x                                                                       4x 4x 4x 4x      
import { promises as fs } from "fs";
import path from "path";
import { TRANSLATION_ROOT_PATH, TRANSLATION_FILES, BASE_LOCALE } from "./constants";
import { FileService } from "./file-service";
import { JsonService, NestedTranslations } from "./json-service";
import { TranslationFile, TranslationLocale, TranslationRow, TranslationStatus } from "./types";
 
/**
 * Validates if a directory is a valid locale directory (e.g., 'en', 'fr', 'de')
 * @param dir Directory name to validate
 * @returns True if the directory is a valid locale directory
 */
const isValidLocaleDirectory = (dir: string): boolean => /^[a-z]{2}(-[A-Z]{2})?$/.test(dir);
 
/**
 * Manages translation files across multiple locales.
 * Handles reading, writing, and generating translation files in a structured format.
 */
export class LocaleManager {
  private fileService = new FileService();
  private jsonService = new JsonService();
  public readonly rootPath: string;
  public readonly translationFiles: TranslationFile[];
 
  /**
   * Initializes the TranslationManager with default paths and files.
   * Automatically updates generated translations for each file.
   */
  constructor() {
    this.rootPath = TRANSLATION_ROOT_PATH;
    this.translationFiles = TRANSLATION_FILES;
  }
 
  /**
   * Gets all available locale directories (e.g., 'en', 'fr', 'de')
   * @returns Array of valid locale identifiers
   * @throws If the root directory cannot be read
   */
  private async getLocales(): Promise<TranslationLocale[]> {
    const files = await fs.readdir(this.rootPath);
    return files.filter((f) => isValidLocaleDirectory(f)) as TranslationLocale[];
  }
 
  /**
   * Update all the generated translation files in the memory
   */
  async updateAllGeneratedTranslations(): Promise<void> {
    for (const file of this.translationFiles) {
      await this.updateGeneratedTranslations(file);
    }
  }
 
  /**
   * Constructs the full file path for a translation file
   * @param locale The locale identifier (e.g., 'en', 'fr')
   * @param file The translation file category (e.g., "translations", "accessibility", "editor", "core")
   * @returns Absolute path to the translation file
   */
  private getFilePath(locale: TranslationLocale, file: TranslationFile): string {
    return path.join(this.rootPath, locale, `${file}.json`);
  }
 
  /**
   * Retrieves and flattens translations for a specific locale and file
   * @param locale The locale to get translations for
   * @param file The translation file category (e.g., "translations", "accessibility", "editor", "core")
   * @returns Flattened key-value pairs of translations
   */
  async getTranslations(locale: TranslationLocale, file: TranslationFile): Promise<Record<string, string>> {
    const filePath = this.getFilePath(locale, file);
    const raw = await this.fileService.read(filePath);
    if (!raw) return {};
    return this.jsonService.flatten(JSON.parse(raw) as NestedTranslations);
  }
 
  /**
   * Updates a single translation key for a specific locale
   * @param locale The locale to update
   * @param key The translation key (dot-notation path)
   * @param value The new translation value
   * @param file The translation file category (e.g., "translations", "accessibility", "editor", "core")
   * @throws If the file cannot be written
   */
  async updateTranslation(locale: TranslationLocale, key: string, value: string, file: TranslationFile): Promise<void> {
    const filePath = this.getFilePath(locale, file);
    const raw = (await this.fileService.read(filePath)) || "{}";
    const json = JSON.parse(raw) as NestedTranslations;
    this.jsonService.set(json, key, value);
    await this.fileService.write(filePath, JSON.stringify(json, null, 2));
    await this.updateGeneratedTranslations(file);
  }
 
  /**
   * Deletes a translation key from all locales
   * @param key The translation key to delete
   * @param file The translation file category (e.g., "translations", "accessibility", "editor", "core")
   * @throws If any file cannot be written
   */
  async deleteTranslation(key: string, file: TranslationFile): Promise<void> {
    const locales = await this.getLocales();
    for (const locale of locales) {
      const filePath = this.getFilePath(locale, file);
      const raw = (await this.fileService.read(filePath)) || "{}";
      const json = JSON.parse(raw) as NestedTranslations;
      this.jsonService.unset(json, key);
      await this.fileService.write(filePath, JSON.stringify(json, null, 2));
    }
    await this.updateGeneratedTranslations(file);
  }
 
  /**
   * Generates translation rows for all locales in a specific file
   * @param file The translation file category (e.g., "translations", "accessibility", "editor", "core")
   * @returns Array of translation rows with status for each locale
   * @throws If English translations are not found
   */
  async generateTranslationRows(file: TranslationFile): Promise<TranslationRow[]> {
    const locales = await this.getLocales();
    const translations: Record<TranslationLocale, Record<string, string>> = {
      en: {},
      cs: {},
      de: {},
      es: {},
      fr: {},
      id: {},
      it: {},
      ja: {},
      ko: {},
      pl: {},
      "pt-BR": {},
      ro: {},
      ru: {},
      sk: {},
      ua: {},
      "vi-VN": {},
      "zh-CN": {},
      "zh-TW": {},
      "tr-TR": {},
    };
 
    for (const locale of locales) {
      translations[locale] = await this.getTranslations(locale, file);
    }
 
    const en = translations[BASE_LOCALE];
    Iif (!en) throw new Error("English translations not found");
 
    return Object.keys(en).map((key) => ({
      id: key,
      key,
      fullPath: path.join(this.rootPath, key),
      translations: Object.fromEntries(
        locales.map((locale) => [
          locale,
          {
            status: translations[locale]?.[key] ? "added" : "missing",
            value: translations[locale]?.[key] || "",
          } as TranslationStatus,
        ])
      ) as Record<TranslationLocale, TranslationStatus>,
    }));
  }
 
  /**
   * Updates multiple translations for a single key across multiple locales
   * @param key The translation key to update
   * @param translations Map of locale to translation value
   * @param file The translation file category
   * @throws If any translation update fails
   */
  async bulkUpdateTranslations(
    key: string,
    translations: Record<TranslationLocale, string>,
    file: TranslationFile
  ): Promise<void> {
    await Promise.all(
      Object.entries(translations).map(([locale, value]) =>
        this.updateTranslation(locale as TranslationLocale, key, value, file)
      )
    );
  }
 
  /**
   * Updates the generated translation file for a specific category
   * @param file The translation file category
   * @throws If the temporary directory cannot be created or the file cannot be written
   */
  async updateGeneratedTranslations(file: TranslationFile): Promise<void> {
    const data = await this.generateTranslationRows(file);
    const tempDir = path.join(__dirname, ".temp");
    await fs.mkdir(tempDir, { recursive: true });
    await this.fileService.write(path.join(tempDir, `generated-${file}.json`), JSON.stringify(data, null, 2));
  }
}