@lingui/cli
Version:
Lingui CLI to extract messages, compile catalogs, and manage translation workflows
251 lines (250 loc) • 9.24 kB
JavaScript
import fs from "fs";
import path from "path";
import { globSync } from "node:fs";
import normalize from "normalize-path";
import { getTranslationsForCatalog, } from "./catalog/getTranslationsForCatalog.js";
import { mergeCatalog } from "./catalog/mergeCatalog.js";
import { extractFromFiles, extractFromFilesWithWorkerPool, } from "./catalog/extractFromFiles.js";
import { isDirectory, makePathRegexSafe, normalizeRelativePath, replacePlaceholders, writeFile, } from "./utils.js";
const LOCALE = "{locale}";
const LOCALE_SUFFIX_RE = /\{locale\}.*$/;
export class Catalog {
config;
name;
path;
include;
exclude;
format;
templateFile;
constructor({ name, path, include, templatePath, format, exclude = [] }, config) {
this.config = config;
this.name = name;
this.path = normalizeRelativePath(path);
this.include = include.map(normalizeRelativePath);
this.exclude = [this.localeDir, ...exclude.map(normalizeRelativePath)];
this.format = format;
this.templateFile =
templatePath ||
getTemplatePath(this.format.getTemplateExtension(), this.path);
}
getFilename(locale) {
return (replacePlaceholders(this.path, { locale }) +
this.format.getCatalogExtension());
}
async make(options) {
const [nextCatalog, prevCatalogs] = await Promise.all([
this.collect({ files: options.files, workerPool: options.workerPool }),
this.readAll(),
]);
if (!nextCatalog)
return false;
const catalogs = this.merge(prevCatalogs, nextCatalog, {
overwrite: options.overwrite,
files: options.files,
});
// Map over all locales and post-process each catalog
const sortedCatalogs = Object.fromEntries(Object.entries(catalogs).map(([locale, catalog]) => {
if (options.clean) {
catalog = cleanObsolete(catalog);
}
catalog = order(options.orderBy, catalog);
return [locale, catalog];
}));
const locales = options.locale ? options.locale : this.locales;
await Promise.all(locales.map((locale) => this.write(locale, sortedCatalogs[locale])));
return sortedCatalogs;
}
async makeTemplate(options) {
const catalog = await this.collect({
files: options.files,
workerPool: options.workerPool,
});
if (!catalog)
return false;
const sorted = order(options.orderBy, catalog);
await this.writeTemplate(sorted);
return sorted;
}
/**
* Collect messages from source paths. Return a raw message catalog as JSON.
*/
async collect(options = {}) {
let paths = this.sourcePaths;
if (options.files) {
options.files = options.files.map((p) => makePathRegexSafe(normalize(p, false)));
const regex = new RegExp(options.files.join("|"), "i");
paths = paths.filter((path) => regex.test(normalize(path)));
}
if (options.workerPool) {
return await extractFromFilesWithWorkerPool(options.workerPool, paths, this.config);
}
return await extractFromFiles(paths, this.config);
}
/*
*
* prevCatalogs - map of message catalogs in all available languages with translations
* nextCatalog - language-agnostic catalog with collected messages
*
* Note: if a catalog in prevCatalogs is null it means the language is available, but
* no previous catalog was generated (usually first run).
*
* Orthogonal use-cases
* --------------------
*
* Message IDs:
* - auto-generated IDs: message is used as a key, `defaults` is not set
* - custom IDs: message is used as `defaults`, custom ID as a key
*
* Source locale (defined by `sourceLocale` in config):
* - catalog for `sourceLocale`: initially, `translation` is prefilled with `defaults`
* (for custom IDs) or `key` (for auto-generated IDs)
* - all other languages: translation is kept empty
*/
merge(prevCatalogs, nextCatalog, options) {
return Object.fromEntries(Object.entries(prevCatalogs).map(([locale, prevCatalog]) => [
locale,
mergeCatalog(prevCatalog, nextCatalog, this.config.sourceLocale === locale, options),
]));
}
async getTranslations(locale, options) {
return await getTranslationsForCatalog(this, locale, options);
}
async write(locale, messages) {
const filename = this.getFilename(locale);
const created = !fs.existsSync(filename);
await this.format.write(filename, messages, locale);
return [created, filename];
}
async writeTemplate(messages) {
const filename = this.templateFile;
await this.format.write(filename, messages);
}
async read(locale) {
return await this.format.read(this.getFilename(locale), locale);
}
async readAll(locales = this.locales) {
const res = {};
await Promise.all(locales.map(async (locale) => {
const catalog = await this.read(locale);
if (catalog) {
res[locale] = catalog;
}
}));
// statement above will save locales in object in undetermined order
// resort here to have keys order the same as in locales definition
return this.locales.reduce((acc, locale) => {
acc[locale] = res[locale];
return acc;
}, {});
}
async readTemplate() {
return await this.format.read(this.templateFile, undefined);
}
get sourcePaths() {
const includeGlobs = this.include.map((includePath) => {
const isDir = isDirectory(includePath);
/**
* glob library results from absolute patterns such as /foo/* are mounted onto the root setting using path.join.
* On windows, this will by default result in /foo/* matching C:\foo\bar.txt.
*/
return isDir
? normalize(path.resolve(process.cwd(), includePath === "/" ? "" : includePath, "**/*.*"))
: includePath;
});
return globSync(includeGlobs, { exclude: this.exclude });
}
get localeDir() {
const localePatternIndex = this.path.indexOf(LOCALE);
if (localePatternIndex === -1) {
throw Error(`Invalid catalog path: ${LOCALE} variable is missing`);
}
return this.path.substring(0, localePatternIndex);
}
get locales() {
return this.config.locales;
}
}
function getTemplatePath(ext, path) {
return path.replace(LOCALE_SUFFIX_RE, "messages" + ext);
}
export function cleanObsolete(messages) {
return Object.fromEntries(Object.entries(messages).filter(([, message]) => !message.obsolete));
}
export function order(by, catalog) {
const orderByFn = typeof by === "function"
? by
: {
messageId: orderByMessageId,
message: orderByMessage,
origin: orderByOrigin,
}[by];
return Object.keys(catalog)
.sort((a, b) => {
return orderByFn({ messageId: a, entry: catalog[a] }, { messageId: b, entry: catalog[b] });
})
.reduce((acc, key) => {
;
acc[key] = catalog[key];
return acc;
}, {});
}
/**
* Object keys are in the same order as they were created
* https://stackoverflow.com/a/31102605/1535540
*/
const orderByMessageId = (a, b) => {
return a.messageId.localeCompare(b.messageId);
};
const orderByOrigin = (a, b) => {
if (!a.entry.origin || !b.entry.origin) {
return 0;
}
function getFirstOrigin(entry) {
const sortedOrigins = entry.origin.sort((a, b) => {
if (a[0] < b[0])
return -1;
if (a[0] > b[0])
return 1;
return 0;
});
return sortedOrigins[0];
}
const [aFile, aLineNumber] = getFirstOrigin(a.entry);
const [bFile, bLineNumber] = getFirstOrigin(b.entry);
if (aFile < bFile)
return -1;
if (aFile > bFile)
return 1;
if (aLineNumber < bLineNumber)
return -1;
if (aLineNumber > bLineNumber)
return 1;
return 0;
};
export async function writeCompiled(path, locale, compiledCatalog, namespace) {
let ext;
switch (namespace) {
case "es":
ext = "mjs";
break;
case "ts":
case "json":
ext = namespace;
break;
default:
ext = "js";
}
const filename = `${replacePlaceholders(path, { locale })}.${ext}`;
await writeFile(filename, compiledCatalog);
return filename;
}
// hardcoded en-US locale to have consistent sorting
// @see https://github.com/lingui/js-lingui/pull/1808
const collator = new Intl.Collator("en-US");
export const orderByMessage = (a, b) => {
const aMsg = a.entry.message || "";
const bMsg = b.entry.message || "";
const aCtxt = a.entry.context || "";
const bCtxt = b.entry.context || "";
return collator.compare(aMsg, bMsg) || collator.compare(aCtxt, bCtxt);
};