UNPKG

@atlassian/i18n-properties-loader

Version:

A webpack loader for i18n *.properties files that can be used in Atlassian Server products

222 lines (166 loc) 6.28 kB
/* eslint-env node */ const fs = require('fs'); const debounce = require('lodash.debounce'); const { PropertiesFile } = require('java-properties'); const { getOptions } = require('loader-utils'); const { validate } = require('schema-utils'); const rx = require('./rx'); const optionsSchema = { type: 'object', properties: { i18nFiles: { type: 'array', description: 'A list of i18n *.properties files', }, disabled: { type: 'boolean', description: 'Should the loader be disabled. By default false', default: false, }, }, additionalProperties: false, }; // See RegExp in atlassian-plugins-webresource-plugin // https://bitbucket.org/atlassian/atlassian-plugins-webresource/src/master/atlassian-plugins-webresource-plugin/src/main/java/com/atlassian/webresource/plugin/i18n/JsI18nTransformer.java#lines-48:63 const i18nRegExp = rx('g')` (?: # Optional module identifier (?: # Optional namespace identifier (?<namespace> # "identifiers can contain only alphanumeric characters (or "$" or "_"), and may not start # with a digit" https://developer.mozilla.org/en-US/docs/Glossary/Identifier (?:[A-Za-z$_][0-9A-Za-z$_]{0,1000})? # optional ["default"] or ['default'] for babel 5, see PLUGWEB-306 (?:\[["']default["']])? # optional .default for babel 6 (?:\.default)? ) # end namespace identifier # Babel 6 and Babel 7 + Webpack 4 (?:\.I18n|\[['"]I18n['"]]) ) # end optional module identifier # A call without a module identifier with a required word boundary |(?:\bI18n) ) # call to getText function \.getText # start parenthesis \(\s* # single or double quote world ["'](?<key>[\w\.\-\s]+)["'] # end parenthesis, or start-of-args \s*(?<endParam>[,|\)]) `; const namedEsmImportRegexp = rx('gm')` ^\s? # possible white-spaces at the beginning import\s+ \{\s*I18n\s*\}\s+ # The named ESM import from\s+ ['"\`] (?<module> # List of supported modules (?: # NPM package "@atlassian/wrm-react-i18n" @atlassian\/wrm-react-i18n | # Built-in WRM module wrm\/i18n ) ) ['"\`] `; let i18nCache = new Map(); // small enough that i18n file changes are propagated. Large enough for cache hits. const accessThreshold = 1000; const clearCacheWhenIdle = debounce(() => { i18nCache = new Map(); }, accessThreshold); function processI18nFiles(i18nFiles) { clearCacheWhenIdle(); if (i18nCache.has(i18nFiles)) { return i18nCache.get(i18nFiles); } const missingFiles = i18nFiles.filter((file) => !fs.existsSync(file) || !fs.statSync(file).isFile()); if (missingFiles.length) { const err = new Error('I18n Loader: i18n files are missing\n\t' + missingFiles.join('\n\t')); err.missingFiles = missingFiles; throw err; } const properties = new PropertiesFile(); i18nFiles.forEach((file) => properties.addFile(file)); i18nCache.set(i18nFiles, properties); return properties; } module.exports = function (source, map) { const options = getOptions(this); validate(optionsSchema, options, { name: 'i18n-properties-loader' }); const { i18nFiles = [], disabled = false } = options; // Skip loader when e.g. we are not running webpack in dev mode if (disabled) { return this.callback(null, source, map); } i18nFiles.forEach((file) => this.addDependency(file)); const callback = this.async(); const isNamedEsmImport = Boolean(source.match(namedEsmImportRegexp)); try { let esmFormatFunctionName = 'format'; // Replace the named ESM import of "getText" call to "format" if (isNamedEsmImport) { esmFormatFunctionName = '__format__'; source = source.replace(namedEsmImportRegexp, (match, module) => { return `\nimport { format as ${esmFormatFunctionName} } from "${module}"`; }); } // Replace "I18n.getText('some.translation.key', [param1], [paramN])" with the real translation string const properties = processI18nFiles(i18nFiles); const newSource = source.replace(i18nRegExp, (...args) => { const { namespace, key, endParam } = args.pop(); const escapedKey = key.replace(/\s/g, '\\ '); let message = properties.get(escapedKey); if (message === undefined) { this.emitWarning(`I18n Loader: Can not find "${key}" phrase ID in your *.properties files`); // Fallback to value of "key" so we can avoid breaking JavaScript syntax when // we have additional parameters we need to substitute message = key; } const formatFunctionCall = isNamedEsmImport ? esmFormatFunctionName : `${namespace}.format`; // Output string const escapedMessage = JSON.stringify(message); if (endParam === ',') { // We don't need to unquote the string since the WRM.format function has all the logic to do that return `${formatFunctionCall}(${escapedMessage},`; } return unquoteMessage(escapedMessage); }); callback(null, newSource, map); } catch (error) { if (error.missingFiles) { error.missingFiles.forEach((file) => this.emitError(`I18n Loader: File ${file} does not exists`)); } else { this.emitError(error); } callback(error); } }; // https://docs.oracle.com/javase/8/docs/api/java/text/MessageFormat.html function unquoteMessage(input) { const match = input.match(/'/g); if (!match) { return input; } // Edge-cases: If there is an only single quote character this means that the message is misformatted. // The ' char should be used in pairs to quote values. if (match.length === 1) { return input.replace("'", ''); } input = input // Unquote any quoted values // Within a String, a pair of single quotes can be used to quote any arbitrary characters except single quotes. .replace(/'([^']+)'/g, '$1') // Unquote remaining single quotes // A single quote itself must be represented by doubled single quotes '' throughout a String .replace("''", "'"); return input; } /** @visibleForTesting */ module.exports._i18nRegExp = i18nRegExp; module.exports._namedEsmImportRegexp = namedEsmImportRegexp;