UNPKG

@lingui/cli

Version:

Lingui CLI to extract messages, compile catalogs, and manage translation workflows

251 lines (250 loc) 9.24 kB
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); };