util/bux/translation-functions.ts (279 lines of code) (raw):
/* eslint-disable no-console */
import * as fs from "fs";
import { EOL } from "os";
import { promisify } from "util";
import { glob as globF } from "glob";
import * as jsyaml from "js-yaml";
import * as util from "util";
import * as path from "path";
type StringMap<V> = { [key: string]: V };
type NestedStringMap<V> = StringMap<V> | StringMap<StringMap<V>>;
const writeFile = promisify(fs.writeFile);
const readFileAsync = promisify(fs.readFile);
const writeFileAsync = promisify(fs.writeFile);
// Generate bundled translations for web and desktop directories
export async function mergeAllTranslations(outputPath: string) {
const currentDirectory = process.cwd();
const rootDir = path.resolve(currentDirectory, "..");
// Ensure the output directory exists
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
// Define resource directories (absolute paths)
const resourceDirs = [
path.join(rootDir, "packages/bonito-core/resources/i18n/json"),
path.join(rootDir, "packages/bonito-ui/resources/i18n/json"),
path.join(rootDir, "packages/playground/resources/i18n/json"),
path.join(rootDir, "packages/react/resources/i18n/json"),
path.join(rootDir, "packages/service/resources/i18n/json"),
];
// Build the English file for each package before building web/desktop translations (it is a prerequisite)
const buildPackageEnglishPromises = [];
buildPackageEnglishPromises.push(
createEnglishTranslations(
path.join(rootDir, "packages/bonito-core/src"),
path.join(rootDir, "packages/bonito-core/i18n"),
path.join(rootDir, "packages/bonito-core/resources/i18n/json"),
"bonito.core"
)
);
buildPackageEnglishPromises.push(
createEnglishTranslations(
path.join(rootDir, "packages/bonito-ui/src"),
path.join(rootDir, "packages/bonito-ui/i18n"),
path.join(rootDir, "packages/bonito-ui/resources/i18n/json"),
"bonito.ui"
)
);
buildPackageEnglishPromises.push(
createEnglishTranslations(
path.join(rootDir, "packages/react/src"),
path.join(rootDir, "packages/react/i18n"),
path.join(rootDir, "packages/react/resources/i18n/json"),
"lib.react"
)
);
buildPackageEnglishPromises.push(
createEnglishTranslations(
path.join(rootDir, "packages/playground/src"),
path.join(rootDir, "packages/playground/i18n"),
path.join(rootDir, "packages/playground/resources/i18n/json"),
"lib.playground"
)
);
buildPackageEnglishPromises.push(
createEnglishTranslations(
path.join(rootDir, "packages/service/src"),
path.join(rootDir, "packages/service/i18n"),
path.join(rootDir, "packages/service/resources/i18n/json"),
"lib.service"
)
);
await Promise.all(buildPackageEnglishPromises);
// Initialize an empty object to store the merged translations
const mergedTranslations: Record<string, Record<string, string>> = {};
// Iterate through each resource directory
for (const dir of resourceDirs) {
// Iterate through each JSON file in the directory
for (const file of fs.readdirSync(dir)) {
if (file.startsWith("resources.") && file.endsWith(".json")) {
const langID = file.split(".")[1];
// If the language ID is not in the object, add it
if (!mergedTranslations[langID]) {
mergedTranslations[langID] = {};
}
// Read the JSON content and parse it
const content = JSON.parse(
await readFileAsync(path.join(dir, file), "utf-8")
);
// Merge the content into the object
Object.assign(mergedTranslations[langID], content);
}
}
}
// Write the merged translations to the output directory
for (const langID of Object.keys(mergedTranslations)) {
const outputFile = path.join(outputPath, `resources.${langID}.json`);
// Read existing translations in the output file if it exists
let existingTranslations = {};
if (fs.existsSync(outputFile)) {
existingTranslations = JSON.parse(
await readFileAsync(outputFile, "utf-8")
);
}
// Merge existing translations with new translations
const combinedTranslations = {
...existingTranslations,
...mergedTranslations[langID],
};
// Sort keys alphabetically
const sortedTranslations = Object.fromEntries(
Object.entries(combinedTranslations).sort()
);
// Write the sorted translations to the output file
await writeFileAsync(
outputFile,
JSON.stringify(sortedTranslations, null, 2),
"utf-8"
);
}
console.log(`Merged translations have been saved in ${outputPath}`);
}
// Helper function that generates the TypeScript interface from the JSON content
function generateTypeScriptInterface(
cleanedContent: Record<string, unknown>
): string {
let tsContent = "export interface GeneratedResourceStrings {\n";
for (const key in cleanedContent) {
tsContent += ` "${key}": string;\n`;
}
tsContent += "}\n";
return tsContent;
}
// Function to generate English file for a package from its YAML files
export async function createEnglishTranslations(
sourcePath: string,
destPath: string,
outputPath: string,
packageName?: string
) {
const translations = await loadDevTranslations(sourcePath, packageName);
const content = JSON.stringify(translations, null, 2).replace(/\n/g, EOL);
const resJsonPath = path.join(destPath, "resources.resjson");
await writeFile(resJsonPath, content);
console.log(
`Saved combined english translations to RESJSON file ${resJsonPath}`
);
// Create JSON English file by parsing the RESJSON file and removing RESJSON-specific syntax and formatting
const resJsonContent = fs.readFileSync(resJsonPath, "utf-8");
const strippedResJsonContent = resJsonContent
.replace(/\/\/.*$/gm, "") // remove line comments
.replace(/\/\*[\s\S]*?\*\//gm, "") // remove block comments
.replace(/,\s*}/gm, "}") // remove trailing commas in objects
.replace(/,\s*]/gm, "]"); // remove trailing commas in arrays
const parsedContent = JSON.parse(strippedResJsonContent);
const cleanContent: Record<string, unknown> = {};
// Iterate through the properties of the object and exclude properties with names starting with an underscore
for (const key in parsedContent) {
if (
Object.hasOwnProperty.call(parsedContent, key) &&
!key.startsWith("_")
) {
cleanContent[key] = parsedContent[key];
}
}
const cleanedJsonContent = JSON.stringify(cleanContent, null, 2);
const resourcesJsonPath = path.join(outputPath, "resources.en.json");
// Check if the directory exists and create it if it doesn't
if (!fs.existsSync(path.dirname(resourcesJsonPath))) {
fs.mkdirSync(path.dirname(resourcesJsonPath), { recursive: true });
}
// Write the cleaned content to the file
fs.writeFileSync(resourcesJsonPath, cleanedJsonContent);
console.log(
`Saved stripped english translations to JSON file ${resourcesJsonPath}`
);
// Generate and save the TypeScript interface
const tsContent = generateTypeScriptInterface(cleanContent);
const resourcesTsPath = path.join(
sourcePath,
"generated/localization/resources.ts"
);
if (!fs.existsSync(path.dirname(resourcesTsPath))) {
fs.mkdirSync(path.dirname(resourcesTsPath), { recursive: true });
}
fs.writeFileSync(resourcesTsPath, tsContent);
console.log(`Saved generated TypeScript interface to ${resourcesTsPath}`);
}
//load-dev-translations.ts
export async function loadDevTranslations(
sourcePath: string,
packageName?: string
): Promise<{ [key: string]: string }> {
const loader = new DevTranslationsLoader();
let hasDuplicate = false;
const translations = await loader.load(sourcePath, (key, file) => {
console.warn(`${key} is being duplicated. "${file}"`);
hasDuplicate = true;
});
if (hasDuplicate) {
throw new Error();
}
return Array.from(translations).reduce(
(obj: Record<string, string>, [key, value]) => {
if (packageName) {
obj[packageName + "." + key] = value;
} else {
obj[key] = value;
}
// Sort the entries by key in alphabetical order
const sortedObj: { [key: string]: string } = {};
Object.keys(obj)
.sort()
.forEach((key) => (sortedObj[key] = obj[key]));
return sortedObj;
},
{}
);
}
//dev-translations-loader.ts
const glob = util.promisify(globF);
const readFile = util.promisify(fs.readFile);
type DuplicateCallback = (key: string, source: string) => void;
export class DevTranslationsLoader {
public translationFiles!: string[];
public translations = new Map<string, string>();
public async load(
sourcePath: string,
duplicateCallback: DuplicateCallback
): Promise<Map<string, string>> {
this.translations.clear();
if (!this.translationFiles) {
const resolvedSourcePath = path.resolve(sourcePath);
this.translationFiles = await glob(
`${resolvedSourcePath}/**/*.i18n.yml`,
{
ignore: "node_modules/**/*",
}
);
}
await this._processFiles(this.translationFiles, duplicateCallback);
return this.translations;
}
private async _processFiles(
files: string[],
duplicateCallback: DuplicateCallback
) {
return Promise.all(
files.map((x) => this._readTranslationFile(x, duplicateCallback))
);
}
private async _readTranslationFile(
path: string,
duplicateCallback: DuplicateCallback
) {
const content = await readFile(path);
this._mergeTranslations(
this._flatten(
jsyaml.load(content.toString()) as NestedStringMap<string>
),
path,
duplicateCallback
);
}
private _flatten(translations: NestedStringMap<string>): StringMap<string> {
const output: StringMap<string> = {};
function step(
object: NestedStringMap<string>,
prev: string | null = null,
currentDepth: number = 0
) {
currentDepth = currentDepth || 1;
for (const key of Object.keys(object)) {
const newKey = prev ? prev + "." + key : key;
const value = object[key];
if (typeof value === "string") {
output[newKey] = value;
} else if (value instanceof Object) {
if (Object.keys(value).length > 0) {
step(value, newKey, currentDepth + 1);
}
} else {
throw new Error(`Invalid translation value for ${newKey}`);
}
}
}
step(translations);
return output;
}
private _mergeTranslations(
translations: StringMap<string>,
source: string,
duplicateCallback: DuplicateCallback
) {
if (process.env.NODE_ENV !== "production") {
for (const key of Object.keys(translations)) {
if (this.translations.has(key)) {
duplicateCallback(key, source);
}
}
}
for (const key of Object.keys(translations)) {
this.translations.set(key, translations[key]);
}
}
}