@salesforce/core
Version:
Core libraries to interact with SFDX projects, orgs, and APIs.
556 lines • 24.4 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
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Messages = void 0;
const fs = __importStar(require("node:fs"));
const os = __importStar(require("node:os"));
const path = __importStar(require("node:path"));
const util = __importStar(require("node:util"));
const node_url_1 = require("node:url");
const ts_types_1 = require("@salesforce/ts-types");
const kit_1 = require("@salesforce/kit");
const logger_1 = require("./logger/logger");
const sfError_1 = require("./sfError");
const getKey = (packageName, bundleName) => `${packageName}:${bundleName}`;
const REGEXP_NO_CONTENT = /^\s*$/g;
const REGEXP_NO_CONTENT_SECTION = /^#\s*/gm;
const REGEXP_MD_IS_LIST_ROW = /^[*-]\s+|^ {2}/;
const REGEXP_MD_LIST_ITEM = /^[*-]\s+/gm;
const markdownLoader = (filePath, fileContents) => {
const map = new Map();
const hasContent = (lineItem) => !REGEXP_NO_CONTENT.exec(lineItem);
// Filter out sections that don't have content
const sections = fileContents.split(REGEXP_NO_CONTENT_SECTION).filter(hasContent);
for (const section of sections) {
const lines = section.split('\n');
const firstLine = lines.shift();
const rest = lines.join('\n').trim();
if (firstLine && rest.length > 0) {
const key = firstLine.trim();
const nonEmptyLines = lines.filter((line) => !!line.trim());
// If every entry in the value is a list item, then treat this as a list. Indented lines are part of the list.
if (nonEmptyLines.every((line) => REGEXP_MD_IS_LIST_ROW.exec(line))) {
const listItems = rest.split(REGEXP_MD_LIST_ITEM).filter(hasContent);
const values = listItems.map((item) => item
.split('\n')
// new lines are ignored in markdown lists
.filter((line) => !!line.trim())
// trim off the indentation
.map((line) => line.trim())
// put it back together
.join('\n'));
map.set(key, values);
}
else {
map.set(key, rest);
}
}
else {
// use error instead of SfError because messages.js should have no internal dependencies.
throw new Error(`Invalid markdown message file: ${filePath}\nThe line "# <key>" must be immediately followed by the message on a new line.`);
}
}
return map;
};
const jsAndJsonLoader = (filePath, fileContents) => {
let json;
try {
json = JSON.parse(fileContents);
if (!(0, 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
const error = err;
if (error.message.startsWith('Unexpected token')) {
const parseError = new Error(`Invalid JSON content in message file: ${filePath}\n${error.message}`);
parseError.name = error.name;
throw parseError;
}
throw err;
}
return new Map(Object.entries(json));
};
/**
* 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 the following formates.
*
* A `.json` file:
* ```json
* {
* "msgKey": "A message displayed in the user",
* "msgGroup": {
* "anotherMsgKey": "Another message displayed to the user"
* },
* "listOfMessage": ["message1", "message2"]
* }
* ```
*
* A `.js` file:
* ```javascript
* module.exports = {
* msgKey: 'A message displayed in the user',
* msgGroup: {
* anotherMsgKey: 'Another message displayed to the user'
* },
* listOfMessage: ['message1', 'message2']
* }
* ```
*
* A `.md` file:
* ```markdown
* # msgKey
* A message displayed in the user
*
* # msgGroup.anotherMsgKey
* Another message displayed to the user
*
* # listOfMessage
* - message1
* - message2
* ```
*
* 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}
*
* **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.
*
* ```typescript
* // Create loader functions for all files in the messages directory
* Messages.importMessagesDirectory(__dirname);
*
* // or, for ESM code
* Messages.importMessagesDirectoryFromMetaUrl(import.meta.url)
*
* // 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('authInfoCreationError');
* ```
*/
class 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
static loaders = new Map();
// A map cache of messages bundles that have already been loaded
static bundles = new Map();
/**
* The locale of the messages in this bundle.
*/
locale;
/**
* The bundle name.
*/
bundleName;
/**
* 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;
}
/**
* 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
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
static readFile = (filePath) => require(filePath);
/**
* 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(getKey(packageName, bundle), 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) {
const ext = path.extname(filePath);
if (!['.json', '.js', '.md'].includes(ext)) {
throw new Error(`Only json, js and md message files are allowed, not ${ext}: ${filePath}`);
}
return (locale) => {
let fileContents;
let parser;
if (ext === '.md') {
fileContents = fs.readFileSync(filePath, 'utf-8');
parser = markdownLoader;
}
else {
// Anything can be returned by a js file, so stringify the results to ensure valid json is returned.
fileContents = JSON.stringify(Messages.readFile(filePath));
// If the file is empty, JSON.stringify will turn it into "" which will validate on parse.
if (fileContents === 'null' || fileContents === '""')
fileContents = '';
parser = jsAndJsonLoader;
}
if (!fileContents || fileContents.trim().length === 0) {
// messages.js should have no internal dependencies.
const error = new Error(`Invalid message file: ${filePath}. No content.`);
error.name = 'SfError';
throw error;
}
const map = parser(filePath, fileContents);
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) {
const bundleName = path.basename(filePath, path.extname(filePath));
if (!Messages.isCached(packageName, bundleName)) {
this.setLoaderFunction(packageName, bundleName, Messages.generateFileLoaderFunction(bundleName, filePath));
}
}
/**
* Support ESM plugins who can't use __dirname
*
* @param metaUrl pass in `import.meta.url`
* @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 importMessagesDirectoryFromMetaUrl(metaUrl, truncateToProjectPath = true, packageName) {
return Messages.importMessagesDirectory(path.dirname((0, node_url_1.fileURLToPath)(metaUrl)), truncateToProjectPath, packageName);
}
/**
* 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;
}
const resolvedPackageName = packageName ?? resolvePackageName(moduleMessagesDirPath);
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(resolvedPackageName, 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}.
*
* ```typescript
* Messages.importMessagesDirectory(__dirname);
* const messages = Messages.loadMessages('packageName', 'bundleName');
* ```
*
* @param packageName The name of the npm package.
* @param bundleName The name of the bundle to load.
*/
static loadMessages(packageName, bundleName) {
const key = getKey(packageName, bundleName);
let messages;
if (this.isCached(packageName, bundleName)) {
messages = this.bundles.get(key);
}
else if (this.loaders.has(key)) {
const loader = this.loaders.get(key);
if (loader) {
messages = loader(Messages.getLocale());
this.bundles.set(key, messages);
messages = this.bundles.get(key);
}
}
if (messages) {
return messages;
}
// Don't use messages inside messages
throw new sfError_1.SfError(`Missing bundle ${key} for locale ${Messages.getLocale()}.`, 'MissingBundleError');
}
/**
* 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(getKey(packageName, bundleName));
}
/**
* Get a message using a message key and use the tokens as values for tokenization.
*
* If the key happens to be an array of messages, it will combine with OS.EOL.
*
* @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
* **Note** Unlike util.format(), specifiers are required for a token to be rendered.
*/
getMessage(key, tokens = []) {
return this.getMessageWithMap(key, tokens, this.messages).join(os.EOL);
}
/**
* Get messages using a message key and use the tokens as values for tokenization.
*
* This will return all messages if the key is an array in the messages file.
*
* ```json
* {
* "myKey": [ "message1", "message2" ]
* }
* ```
*
* ```markdown
* # myKey
* * message1
* * message2
* ```
*
* @param key The key of the messages.
* @param tokens The values to substitute in the message.
*
* **See** https://nodejs.org/api/util.html#util_util_format_format_args
* **Note** Unlike util.format(), specifiers are required for a token to be rendered.
*/
getMessages(key, tokens = []) {
return this.getMessageWithMap(key, tokens, this.messages);
}
/**
* Convenience method to create errors using message labels.
*
* `error.name` will be the upper-cased key, remove prefixed `error.` and will always end in Error.
* `error.actions` will be loaded using `${key}.actions` if available.
*
* @param key The key of the error message.
* @param tokens The error message tokens.
* @param actionTokens The action messages tokens.
* @param exitCodeOrCause The exit code which will be used by SfdxCommand or the underlying error that caused this error to be raised.
* @param cause The underlying error that caused this error to be raised.
*/
createError(key, tokens = [], actionTokens = [], exitCodeOrCause, cause) {
const { message, name, actions } = this.formatMessageContents({
type: 'error',
key,
tokens,
actionTokens,
});
return new sfError_1.SfError(message, name, actions, exitCodeOrCause, cause);
}
/**
* Convenience method to create warning using message labels.
*
* `warning.name` will be the upper-cased key, remove prefixed `warning.` and will always end in Warning.
* `warning.actions` will be loaded using `${key}.actions` if available.
*
* @param key The key of the warning message.
* @param tokens The warning message tokens.
* @param actionTokens The action messages tokens.
*/
createWarning(key, tokens = [], actionTokens = []) {
return this.formatMessageContents({ type: 'warning', key, tokens, actionTokens });
}
/**
* Convenience method to create info using message labels.
*
* `info.name` will be the upper-cased key, remove prefixed `info.` and will always end in Info.
* `info.actions` will be loaded using `${key}.actions` if available.
*
* @param key The key of the warning message.
* @param tokens The warning message tokens.
* @param actionTokens The action messages tokens.
*/
createInfo(key, tokens = [], actionTokens = []) {
return this.formatMessageContents({ type: 'info', key, tokens, actionTokens });
}
/**
* Formats message contents given a message type, key, tokens and actions tokens
*
* `<type>.name` will be the upper-cased key, remove prefixed `<type>.` and will always end in 'Error | Warning | Info.
* `<type>.actions` will be loaded using `${key}.actions` if available.
*
* @param type The type of the message set must 'error' | 'warning' | 'info'.
* @param key The key of the warning message.
* @param tokens The warning message tokens.
* @param actionTokens The action messages tokens.
* @param preserveName Do not require that the name end in the type ('error' | 'warning' | 'info').
*/
formatMessageContents({ type, key, tokens = [], actionTokens = [], preserveName = false, }) {
const label = (0, kit_1.upperFirst)(type);
const labelRegExp = new RegExp(`${label}$`);
const searchValue = type === 'error' ? /^error.*\./ : new RegExp(`^${type}.`);
// Convert key to name:
// 'myMessage' -> `MyMessageWarning`
// 'myMessageError' -> `MyMessageError`
// 'warning.myMessage' -> `MyMessageWarning`
const name = `${(0, kit_1.upperFirst)(key.replace(searchValue, ''))}${labelRegExp.exec(key) ?? preserveName ? '' : label}`;
const message = this.getMessage(key, tokens);
let actions;
try {
actions = this.getMessageWithMap(`${key}.actions`, actionTokens, this.messages);
}
catch (e) {
/* just ignore if actions aren't found */
}
return { message, name, actions };
}
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 && (0, ts_types_1.isJsonMap)(childObject)) {
const childMap = new Map(Object.entries(childObject));
return this.getMessageWithMap(childKey, tokens, childMap);
}
}
const msg = map.get(key);
if (!msg) {
// Don't use messages inside messages
throw new sfError_1.SfError(`Missing message ${this.bundleName}:${key} for locale ${Messages.getLocale()}.`, 'MissingMessageError');
}
const messages = (0, kit_1.ensureArray)(msg);
return messages.map((message) => {
const msgStr = (0, ts_types_1.ensureString)(message);
// If the message does not contain a specifier, util.format still appends the token to the end.
// The 'markdownLoader' automatically splits bulleted lists into arrays.
// This causes the token to be appended to each line regardless of the presence of a specifier.
// Here we check for the presence of a specifier and only format the message if one is present.
// https://nodejs.org/api/util.html#utilformatformat-args
// https://regex101.com/r/8Hf8Z6/1
const specifierRegex = new RegExp('%[sdifjoO]{1}', 'gm');
const specifierFound = specifierRegex.test(msgStr);
if (!specifierFound && tokens.length > 0) {
const logger = logger_1.Logger.childFromRoot('core:messages');
logger.warn(`Unable to render tokens in message. Ensure a specifier (e.g. %s) exists in the message:\n${msgStr}`);
}
return specifierFound ? util.format(msgStr, ...tokens) : msgStr;
});
}
}
exports.Messages = Messages;
const resolvePackageName = (moduleMessagesDirPath) => {
const errMessage = `Invalid or missing package.json file at '${moduleMessagesDirPath}'. If not using a package.json, pass in a packageName.`;
try {
const resolvedPackageName = (0, ts_types_1.asString)((0, ts_types_1.ensureJsonMap)(Messages.readFile(path.join(moduleMessagesDirPath, 'package.json'))).name);
if (!resolvedPackageName) {
throw sfError_1.SfError.create({ message: errMessage, name: 'MissingPackageName' });
}
return resolvedPackageName;
}
catch (err) {
throw sfError_1.SfError.create({ message: errMessage, name: 'MissingPackageName', cause: err });
}
};
//# sourceMappingURL=messages.js.map
;