/* Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import webpack from "webpack"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import _ from "lodash"; import { type Translations } from "matrix-web-i18n"; interface Options { // Path to the strings for the application, will be used to deduce what languages are supported stringsPath: string; // Additional paths to strings which will be merged into the application's own strings for supported languages additionalStringsPaths?: string[]; } async function exists(path: string): Promise { try { await fs.access(path); return true; } catch { return false; } } export class I18nWebpackPlugin { private readonly options: Options; public constructor(options: Options) { this.options = options; } public apply(compiler: webpack.Compiler): void { const { RawSource } = compiler.webpack.sources; compiler.hooks.thisCompilation.tap("I18nWebpackPlugin", (compilation) => { compilation.hooks.processAssets.tapPromise( { name: "I18nWebpackPlugin", stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, }, async () => { const paths = [this.options.stringsPath, ...(this.options.additionalStringsPaths ?? [])].map((p) => path.resolve(compiler.context, p), ); const logger = compilation.getLogger("I18nWebpackPlugin"); for (const p of paths) { compilation.contextDependencies.add(p); if (!(await exists(p))) { compilation.errors.push( new webpack.WebpackError(`I18nWebpackPlugin: strings path not found: ${p}`), ); return; } } const primaryPath = paths[0]; const includeLangs = [...new Set([...(await fs.readdir(primaryPath))])] .filter((fn) => fn.endsWith(".json")) .map((f) => f.slice(0, -5)); const langFileMap: Record = {}; for (const lang of includeLangs) { let translations: Translations = {}; for (const p of paths) { const f = path.join(p, lang + ".json"); if (await exists(f)) { try { const content = await fs.readFile(f, "utf-8"); translations = _.merge(translations, JSON.parse(content)); compilation.fileDependencies.add(f); } catch (e) { compilation.errors.push( new webpack.WebpackError( `I18nWebpackPlugin: Failed to read or parse ${f}: ${e}`, ), ); } } } const json = JSON.stringify(translations, null, 4); const jsonBuffer = Buffer.from(json); const digest = compiler.webpack.util .createHash("xxhash64") .update(jsonBuffer) .digest("hex") .slice(0, 7); const filename = `${lang}.${digest}.json`; compilation.emitAsset(`i18n/${filename}`, new RawSource(jsonBuffer)); langFileMap[lang] = filename; logger.debug(`Generated language file: ${filename}`); } // Generate languages.json const languages: Record = {}; includeLangs.forEach((lang) => { const normalizedLanguage = lang.toLowerCase().replace("_", "-"); const languageParts = normalizedLanguage.split("-"); if (languageParts.length == 2 && languageParts[0] == languageParts[1]) { languages[languageParts[0]] = langFileMap[lang]; } else { languages[normalizedLanguage] = langFileMap[lang]; } }); compilation.emitAsset("i18n/languages.json", new RawSource(JSON.stringify(languages, null, 4))); logger.info("Generated languages.json and language files"); }, ); }); } }