@salesforce/core
Version:
Core libraries to interact with SFDX projects, orgs, and APIs.
307 lines • 13.5 kB
JavaScript
;
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Messages = void 0;
const fs = require("fs");
const os = require("os");
const path = require("path");
const util = require("util");
const ts_types_1 = require("@salesforce/ts-types");
const kit_1 = require("@salesforce/kit");
class Key {
constructor(packageName, bundleName) {
this.packageName = packageName;
this.bundleName = bundleName;
}
toString() {
return `${this.packageName}:${this.bundleName}`;
}
}
/**
* The core message framework manages messages and allows them to be accessible by
* all plugins and consumers of sfdx-core. It is set up to handle localization down
* the road at no additional effort to the consumer. Messages can be used for
* anything from user output (like the console), to error messages, to returned
* data from a method.
*
* Messages are loaded from loader functions. The loader functions will only run
* when a message is required. This prevents all messages from being loaded into memory at
* application startup. The functions can load from memory, a file, or a server.
*
* In the beginning of your app or file, add the loader functions to be used later. If using
* json or js files in a root messages directory (`<moduleRoot>/messages`), load the entire directory
* automatically with {@link Messages.importMessagesDirectory}. Message files must be in `.json` or `.js`
* that exports a json object with **only** top level key-value pairs. The values support
* [util.format](https://nodejs.org/api/util.html#util_util_format_format_args) style strings
* that apply the tokens passed into {@link Message.getMessage}
*
* A sample message file.
* ```
* {
* 'msgKey': 'A message displayed in the terminal'
* }
* ```
*
* **Note:** When running unit tests individually, you may see errors that the messages aren't found.
* This is because `index.js` isn't loaded when tests run like they are when the package is required.
* To allow tests to run, import the message directory in each test (it will only
* do it once) or load the message file the test depends on individually.
*
* ```
* // Create loader functions for all files in the messages directory
* Messages.importMessagesDirectory(__dirname);
*
* // Now you can use the messages from anywhere in your code or file.
* // If using importMessageDirectory, the bundle name is the file name.
* const messages : Messages = Messages.loadMessages(packageName, bundleName);
*
* // Messages now contains all the message in the bundleName file.
* messages.getMessage('JsonParseError');
* ```
*/
class Messages {
/**
* Create a new messages bundle.
*
* **Note:** Use {Messages.loadMessages} unless you are writing your own loader function.
*
* @param bundleName The bundle name.
* @param locale The locale.
* @param messages The messages. Can not be modified once created.
*/
constructor(bundleName, locale, messages) {
this.messages = messages;
this.bundleName = bundleName;
this.locale = locale;
}
/**
* Get the locale. This will always return 'en_US' but will return the
* machine's locale in the future.
*/
static getLocale() {
return 'en_US';
}
/**
* Set a custom loader function for a package and bundle that will be called on {@link Messages.loadMessages}.
*
* @param packageName The npm package name.
* @param bundle The name of the bundle.
* @param loader The loader function.
*/
static setLoaderFunction(packageName, bundle, loader) {
this.loaders.set(new Key(packageName, bundle).toString(), loader);
}
/**
* Generate a file loading function. Use {@link Messages.importMessageFile} unless
* overriding the bundleName is required, then manually pass the loader
* function to {@link Messages.setLoaderFunction}.
*
* @param bundleName The name of the bundle.
* @param filePath The messages file path.
*/
static generateFileLoaderFunction(bundleName, filePath) {
return (locale) => {
// Anything can be returned by a js file, so stringify the results to ensure valid json is returned.
const fileContents = JSON.stringify(Messages.readFile(filePath));
// If the file is empty, JSON.stringify will turn it into "" which will validate on parse, so throw.
if (!fileContents || fileContents === 'null' || fileContents === '""') {
const error = new Error(`Invalid message file: ${filePath}. No content.`);
error.name = 'SfdxError';
throw error;
}
let json;
try {
json = JSON.parse(fileContents);
if (!ts_types_1.isObject(json)) {
// Bubble up
throw new Error(`Unexpected token. Found returned content type '${typeof json}'.`);
}
}
catch (err) {
// Provide a nicer error message for a common JSON parse error; Unexpected token
if (err.message.startsWith('Unexpected token')) {
const parseError = new Error(`Invalid JSON content in message file: ${filePath}\n${err.message}`);
parseError.name = err.name;
throw parseError;
}
throw err;
}
const map = new Map(Object.entries(json));
return new Messages(bundleName, locale, map);
};
}
/**
* Add a single message file to the list of loading functions using the file name as the bundle name.
* The loader will only be added if the bundle name is not already taken.
*
* @param packageName The npm package name.
* @param filePath The path of the file.
*/
static importMessageFile(packageName, filePath) {
if (path.extname(filePath) !== '.json' && path.extname(filePath) !== '.js') {
throw new Error(`Only json and js message files are allowed, not ${path.extname(filePath)}`);
}
const bundleName = path.basename(filePath, path.extname(filePath));
if (!Messages.isCached(packageName, bundleName)) {
this.setLoaderFunction(packageName, bundleName, Messages.generateFileLoaderFunction(bundleName, filePath));
}
}
/**
* Import all json and js files in a messages directory. Use the file name as the bundle key when
* {@link Messages.loadMessages} is called. By default, we're assuming the moduleDirectoryPart is a
* typescript project and will truncate to root path (where the package.json file is). If your messages
* directory is in another spot or you are not using typescript, pass in false for truncateToProjectPath.
*
* ```
* // e.g. If your message directory is in the project root, you would do:
* Messages.importMessagesDirectory(__dirname);
* ```
*
* @param moduleDirectoryPath The path to load the messages folder.
* @param truncateToProjectPath Will look for the messages directory in the project root (where the package.json file is located).
* i.e., the module is typescript and the messages folder is in the top level of the module directory.
* @param packageName The npm package name. Figured out from the root directory's package.json.
*/
static importMessagesDirectory(moduleDirectoryPath, truncateToProjectPath = true, packageName) {
let moduleMessagesDirPath = moduleDirectoryPath;
let projectRoot = moduleDirectoryPath;
if (!path.isAbsolute(moduleDirectoryPath)) {
throw new Error('Invalid module path. Relative URLs are not allowed.');
}
while (projectRoot.length >= 0) {
try {
fs.statSync(path.join(projectRoot, 'package.json'));
break;
}
catch (err) {
if (err.code !== 'ENOENT')
throw err;
projectRoot = projectRoot.substring(0, projectRoot.lastIndexOf(path.sep));
}
}
if (truncateToProjectPath) {
moduleMessagesDirPath = projectRoot;
}
if (!packageName) {
const errMessage = `Invalid or missing package.json file at '${moduleMessagesDirPath}'. If not using a package.json, pass in a packageName.`;
try {
packageName = ts_types_1.asString(ts_types_1.ensureJsonMap(Messages.readFile(path.join(moduleMessagesDirPath, 'package.json'))).name);
if (!packageName) {
throw new kit_1.NamedError('MissingPackageName', errMessage);
}
}
catch (err) {
throw new kit_1.NamedError('MissingPackageName', errMessage, err);
}
}
moduleMessagesDirPath += `${path.sep}messages`;
for (const file of fs.readdirSync(moduleMessagesDirPath)) {
const filePath = path.join(moduleMessagesDirPath, file);
const stat = fs.statSync(filePath);
if (stat) {
if (stat.isDirectory()) {
// When we support other locales, load them from /messages/<local>/<bundleName>.json
// Change generateFileLoaderFunction to handle loading locales.
}
else if (stat.isFile()) {
this.importMessageFile(packageName, filePath);
}
}
}
}
/**
* Load messages for a given package and bundle. If the bundle is not already cached, use the loader function
* created from {@link Messages.setLoaderFunction} or {@link Messages.importMessagesDirectory}.
*
* @param packageName The name of the npm package.
* @param bundleName The name of the bundle to load.
*/
static loadMessages(packageName, bundleName) {
const key = new Key(packageName, bundleName);
let messages;
if (this.isCached(packageName, bundleName)) {
messages = this.bundles.get(key.toString());
}
else if (this.loaders.has(key.toString())) {
const loader = this.loaders.get(key.toString());
if (loader) {
messages = loader(Messages.getLocale());
this.bundles.set(key.toString(), messages);
messages = this.bundles.get(key.toString());
}
}
if (messages) {
return messages;
}
// Don't use messages inside messages
throw new kit_1.NamedError('MissingBundleError', `Missing bundle ${key.toString()} for locale ${Messages.getLocale()}.`);
}
/**
* Check if a bundle already been loaded.
*
* @param packageName The npm package name.
* @param bundleName The bundle name.
*/
static isCached(packageName, bundleName) {
return this.bundles.has(new Key(packageName, bundleName).toString());
}
/**
* Get a message using a message key and use the tokens as values for tokenization.
*
* @param key The key of the message.
* @param tokens The values to substitute in the message.
*
* **See** https://nodejs.org/api/util.html#util_util_format_format_args
*/
getMessage(key, tokens = []) {
return this.getMessageWithMap(key, tokens, this.messages);
}
getMessageWithMap(key, tokens = [], map) {
// Allow nested keys for better grouping
const group = RegExp(/([a-zA-Z0-9_-]+)\.(.*)/).exec(key);
if (group) {
const parentKey = group[1];
const childKey = group[2];
const childObject = map.get(parentKey);
if (childObject && ts_types_1.isAnyJson(childObject)) {
const childMap = new Map(Object.entries(childObject));
return this.getMessageWithMap(childKey, tokens, childMap);
}
}
if (!map.has(key)) {
// Don't use messages inside messages
throw new kit_1.NamedError('MissingMessageError', `Missing message ${this.bundleName}:${key} for locale ${Messages.getLocale()}.`);
}
const msg = map.get(key);
const messages = (ts_types_1.isArray(msg) ? msg : [msg]);
return messages
.map((message) => {
ts_types_1.ensureString(message);
return util.format(message, ...tokens);
})
.join(os.EOL);
}
}
exports.Messages = Messages;
// It would be AWESOME to use Map<Key, Message> but js does an object instance comparison and doesn't let you
// override valueOf or equals for the === operator, which map uses. So, Use Map<String, Message>
// A map of loading functions to dynamically load messages when they need to be used
Messages.loaders = new Map();
// A map cache of messages bundles that have already been loaded
Messages.bundles = new Map();
/**
* Internal readFile. Exposed for unit testing. Do not use util/fs.readFile as messages.js
* should have no internal dependencies.
*
* @param filePath read file target.
* @ignore
*/
Messages.readFile = (filePath) => {
return require(filePath);
};
//# sourceMappingURL=messages.js.map