UNPKG

ember-intl

Version:

Internationalization for Ember projects

282 lines (227 loc) 7.59 kB
const { mkdirSync, readFileSync, statSync, writeFileSync } = require('node:fs'); const { basename, extname, join } = require('node:path'); const CachingWriter = require('broccoli-caching-writer'); const extend = require('extend'); const yaml = require('js-yaml'); const stringify = require('json-stable-stringify'); const Linter = require('./linter'); const forEachMessage = require('./utils/for-each-message'); const isKnownLanguage = require('./utils/is-known-language'); const stripEmptyTranslations = require('./utils/strip-empty-translations'); const wrapWithNamespaceIfNeeded = require('./utils/wrap-with-namespace-if-needed'); const validateMessage = require('../../message-validator/validate-message'); function filterPatterns(locales) { if (Array.isArray(locales)) { return locales.map( (locale) => new RegExp(`${normalizeLocale(locale)}.(json|yaml|yml)$`, 'i'), ); } return null; } function isApp(filePath) { return !filePath.includes('/__ember-intl-addon__/'); } function normalizeLocale(locale) { if (typeof locale === 'string') { return locale.replace(/_/g, '-').trim().toLowerCase(); } return locale; } function readAsObject(filePath) { const data = readFileSync(filePath); const ext = extname(filePath); switch (ext) { case '.json': { return JSON.parse(data); } case '.yaml': case '.yml': { return yaml.load(data); } } } class TranslationReducer extends CachingWriter { constructor(inputNode, options) { if (!Array.isArray(inputNode)) { inputNode = [inputNode]; } const cacheInclude = filterPatterns(options && options.includeLocales); const cacheExclude = filterPatterns(options && options.excludeLocales); super(inputNode, { annotation: 'Translation Reducer', cacheInclude, cacheExclude, }); this.options = { outputPath: '', fallbackLocale: null, log() {}, requiresTranslation(/* key, locale */) { return true; }, mergeTranslationFiles: false, ...options, }; this.options.fallbackLocale = normalizeLocale(this.options.fallbackLocale); this.linter = new Linter({ requiresTranslation: this.options.requiresTranslation, }); } build() { // Call listFiles() from broccoli-caching-writer const translationFilePaths = this.listFiles(); const translations = this.mergeTranslations(translationFilePaths); const lintResults = this.linter.lint(translations); this.handleLintResult(lintResults); const filePath = join(this.outputPath, this.options.outputPath); const fallbackTranslationObject = translations[this.options.fallbackLocale]; mkdirSync(filePath, { recursive: true }); for (const locale in translations) { if (this.options.verbose) { this.validateMessages(translations[locale], locale); } if (fallbackTranslationObject && this.options.fallbackLocale !== locale) { translations[locale] = extend( true, {}, fallbackTranslationObject, translations[locale], ); } writeFileSync( join(filePath, `${locale}.json`), stringify(translations[locale]), { encoding: 'utf8', }, ); } if (this.options.mergeTranslationFiles) { const restructuredTranslations = []; for (const [locale, hash] of Object.entries(translations)) { restructuredTranslations.push([locale, hash]); } writeFileSync( join(filePath, 'translations.js'), 'export default ' + stringify(restructuredTranslations), { encoding: 'utf8', }, ); } } handleLintResult(result) { const { icuMismatch, missingTranslations } = result; const throwingMessages = []; if (icuMismatch.length) { const missingICUArguments = icuMismatch.map(([key, notInLocales]) => { const missingString = notInLocales .map( ([locale, missingICUArgs]) => `"${locale}": ${missingICUArgs .map((arg) => `"${arg}"`) .join(', ')}`, ) .join(', '); return `"${key}" ICU argument mismatch: ${missingString}`; }); if ( this.options.verbose && // log messages if not failing as it's duplicated console output !this.options.errorOnNamedArgumentMismatch ) { missingICUArguments.forEach((message) => this.options.log(message)); } if (this.options.errorOnNamedArgumentMismatch) { throwingMessages.push( 'ICU arguments mismatch:\n' + missingICUArguments.map((text) => `- ${text}`).join('\n'), ); } } if (missingTranslations.length) { const missingTranslationMessages = missingTranslations.map( ([key, notInLocales]) => `"${key}" was not found in ${notInLocales .map((locale) => `"${locale}"`) .join(', ')}`, ); if ( this.options.verbose && // log messages if not failing as it's duplicated console output !this.options.errorOnMissingTranslations ) { missingTranslationMessages.forEach((message) => this.options.log(message), ); } if (this.options.errorOnMissingTranslations) { throwingMessages.push( 'Missing translations:\n' + missingTranslationMessages.map((text) => `- ${text}`).join('\n'), ); } } if (throwingMessages.length) { throw new Error(throwingMessages.join('\n\n')); } } mergeTranslations(filePaths) { const orderedFilePaths = filePaths.sort(function (filePath1, filePath2) { const isApp1 = isApp(filePath1); const isApp2 = isApp(filePath2); if (isApp1 && !isApp2) { return 1; } if (isApp2 && !isApp1) { return -1; } return 0; }); const translations = orderedFilePaths.reduce((accumulator, filePath) => { if (statSync(filePath).isDirectory()) { return accumulator; } let translationObject = readAsObject(filePath); if (this.options.stripEmptyTranslations === true) { translationObject = stripEmptyTranslations(translationObject); } if (!translationObject) { this.options.log(`cannot read path "${filePath}"`); return accumulator; } if (this.options.wrapTranslationsWithNamespace === true) { translationObject = wrapWithNamespaceIfNeeded( translationObject, filePath, this.inputPaths[0], this.options.addonsWithTranslations, ); } const fileName = basename(filePath).split('.')[0]; const locale = normalizeLocale(fileName); return extend(true, accumulator, { [locale]: translationObject, }); }, {}); return translations; } validateMessages(messages, locale) { const language = locale.split('-')[0]; if (!isKnownLanguage(language)) { const message = `${locale}: Unable to detect language data for "${language}". Language code is either unknown or invalid.`; this.options.log(message); return false; } forEachMessage(messages, (key, message) => { try { validateMessage(message, locale); } catch (error) { const message = `${locale}: "${key}" message cannot be parsed: ${error.message}`; this.options.log(message); } }); } } module.exports = TranslationReducer;