UNPKG

@salesforce/core

Version:

Core libraries to interact with SFDX projects, orgs, and APIs.

556 lines 24.4 kB
"use strict"; /* * 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