element-web/apps/web/I18nWebpackPlugin.ts
Michael Telatynski 0a308743b8
Rewrite copy-res i18n build as a webpack plugin (#32664)
* Replace copy-res with a webpack plugin

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Specify writeToDisk=true for webpack-dev-server

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Use async fs methods

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-27 13:00:25 +00:00

124 lines
5.0 KiB
TypeScript

/*
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<boolean> {
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<string, string> = {};
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<string, string> = {};
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");
},
);
});
}
}