@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
JavaScript
/* 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;