UNPKG

13.4 kBJavaScriptView Raw
1"use strict";
2/*
3 * Copyright (c) 2018, salesforce.com, inc.
4 * All rights reserved.
5 * SPDX-License-Identifier: BSD-3-Clause
6 * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
7 */
8/*
9 * NOTE: This is the lowest level class in core and should not import any
10 * other local classes or utils to prevent circular dependencies or testing
11 * stub issues.
12 */
13Object.defineProperty(exports, "__esModule", { value: true });
14const kit_1 = require("@salesforce/kit");
15const ts_types_1 = require("@salesforce/ts-types");
16const fs = require("fs");
17const path = require("path");
18const util = require("util");
19class Key {
20 constructor(packageName, bundleName) {
21 this.packageName = packageName;
22 this.bundleName = bundleName;
23 }
24 toString() {
25 return `${this.packageName}:${this.bundleName}`;
26 }
27}
28/**
29 * The core message framework manages messages and allows them to be accessible by
30 * all plugins and consumers of sfdx-core. It is set up to handle localization down
31 * the road at no additional effort to the consumer. Messages can be used for
32 * anything from user output (like the console), to error messages, to returned
33 * data from a method.
34 *
35 * Messages are loaded from loader functions. The loader functions will only run
36 * when a message is required. This prevents all messages from being loaded into memory at
37 * application startup. The functions can load from memory, a file, or a server.
38 *
39 * In the beginning of your app or file, add the loader functions to be used later. If using
40 * json or js files in a root messages directory (`<moduleRoot>/messages`), load the entire directory
41 * automatically with {@link Messages.importMessagesDirectory}. Message files must be in `.json` or `.js`
42 * that exports a json object with **only** top level key-value pairs. The values support
43 * [util.format](https://nodejs.org/api/util.html#util_util_format_format_args) style strings
44 * that apply the tokens passed into {@link Message.getMessage}
45 *
46 * A sample message file.
47 * ```
48 * {
49 * 'msgKey': 'A message displayed in the terminal'
50 * }
51 * ```
52 *
53 * **Note:** When running unit tests individually, you may see errors that the messages aren't found.
54 * This is because `index.js` isn't loaded when tests run like they are when the package is required.
55 * To allow tests to run, import the message directory in each test (it will only
56 * do it once) or load the message file the test depends on individually.
57 *
58 * ```
59 * // Create loader functions for all files in the messages directory
60 * Messages.importMessagesDirectory(__dirname);
61 *
62 * // Now you can use the messages from anywhere in your code or file.
63 * // If using importMessageDirectory, the bundle name is the file name.
64 * const messages : Messages = Messages.loadMessages(packageName, bundleName);
65 *
66 * // Messages now contains all the message in the bundleName file.
67 * messages.getMessage('JsonParseError');
68 * ```
69 */
70class Messages {
71 /**
72 * Create a new messages bundle.
73 *
74 * **Note:** Use {Messages.loadMessages} unless you are writing your own loader function.
75 * @param bundleName The bundle name.
76 * @param locale The locale.
77 * @param messages The messages. Can not be modified once created.
78 */
79 constructor(bundleName, locale, messages) {
80 this.messages = messages;
81 this.bundleName = bundleName;
82 this.locale = locale;
83 }
84 /**
85 * Get the locale. This will always return 'en_US' but will return the
86 * machine's locale in the future.
87 */
88 static getLocale() {
89 return 'en_US';
90 }
91 /**
92 * Set a custom loader function for a package and bundle that will be called on {@link Messages.loadMessages}.
93 * @param packageName The npm package name.
94 * @param bundle The name of the bundle.
95 * @param loader The loader function.
96 */
97 static setLoaderFunction(packageName, bundle, loader) {
98 this.loaders.set(new Key(packageName, bundle).toString(), loader);
99 }
100 /**
101 * Generate a file loading function. Use {@link Messages.importMessageFile} unless
102 * overriding the bundleName is required, then manually pass the loader
103 * function to {@link Messages.setLoaderFunction}.
104 *
105 * @param bundleName The name of the bundle.
106 * @param filePath The messages file path.
107 */
108 static generateFileLoaderFunction(bundleName, filePath) {
109 return (locale) => {
110 // Anything can be returned by a js file, so stringify the results to ensure valid json is returned.
111 const fileContents = JSON.stringify(Messages._readFile(filePath));
112 // If the file is empty, JSON.stringify will turn it into "" which will validate on parse, so throw.
113 if (!fileContents || fileContents === 'null' || fileContents === '""') {
114 const error = new Error(`Invalid message file: ${filePath}. No content.`);
115 error.name = 'SfdxError';
116 throw error;
117 }
118 let json;
119 try {
120 json = JSON.parse(fileContents);
121 if (!ts_types_1.isObject(json)) {
122 // Bubble up
123 throw new Error(`Unexpected token. Found returned content type '${typeof json}'.`);
124 }
125 }
126 catch (err) {
127 // Provide a nicer error message for a common JSON parse error; Unexpected token
128 if (err.message.startsWith('Unexpected token')) {
129 const parseError = new Error(`Invalid JSON content in message file: ${filePath}\n${err.message}`);
130 parseError.name = err.name;
131 throw parseError;
132 }
133 throw err;
134 }
135 const map = new Map(Object.entries(json));
136 return new Messages(bundleName, locale, map);
137 };
138 }
139 /**
140 * Add a single message file to the list of loading functions using the file name as the bundle name.
141 * The loader will only be added if the bundle name is not already taken.
142 *
143 * @param packageName The npm package name.
144 * @param filePath The path of the file.
145 */
146 static importMessageFile(packageName, filePath) {
147 if (path.extname(filePath) !== '.json' && path.extname(filePath) !== '.js') {
148 throw new Error(`Only json and js message files are allowed, not ${path.extname(filePath)}`);
149 }
150 const bundleName = path.basename(filePath, path.extname(filePath));
151 if (!Messages.isCached(packageName, bundleName)) {
152 this.setLoaderFunction(packageName, bundleName, Messages.generateFileLoaderFunction(bundleName, filePath));
153 }
154 }
155 /**
156 * Import all json and js files in a messages directory. Use the file name as the bundle key when
157 * {@link Messages.loadMessages} is called. By default, we're assuming the moduleDirectoryPart is a
158 * typescript project and will truncate to root path (where the package.json file is). If your messages
159 * directory is in another spot or you are not using typescript, pass in false for truncateToProjectPath.
160 *
161 * ```
162 * // e.g. If your message directory is in the project root, you would do:
163 * Messages.importMessagesDirectory(__dirname);
164 * ```
165 *
166 * @param moduleDirectoryPath The path to load the messages folder.
167 * @param truncateToProjectPath Will look for the messages directory in the project root (where the package.json file is located).
168 * i.e., the module is typescript and the messages folder is in the top level of the module directory.
169 * @param packageName The npm package name. Figured out from the root directory's package.json.
170 */
171 static importMessagesDirectory(moduleDirectoryPath, truncateToProjectPath = true, packageName) {
172 let moduleMessagesDirPath = moduleDirectoryPath;
173 let projectRoot = moduleDirectoryPath;
174 if (!path.isAbsolute(moduleDirectoryPath)) {
175 throw new Error('Invalid module path. Relative URLs are not allowed.');
176 }
177 while (projectRoot.length >= 0) {
178 try {
179 fs.statSync(path.join(projectRoot, 'package.json'));
180 break;
181 }
182 catch (err) {
183 if (err.code !== 'ENOENT')
184 throw err;
185 projectRoot = projectRoot.substring(0, projectRoot.lastIndexOf(path.sep));
186 }
187 }
188 if (truncateToProjectPath) {
189 moduleMessagesDirPath = projectRoot;
190 }
191 if (!packageName) {
192 const errMessage = `Invalid or missing package.json file at '${moduleMessagesDirPath}'. If not using a package.json, pass in a packageName.`;
193 try {
194 packageName = ts_types_1.asString(ts_types_1.ensureJsonMap(Messages._readFile(path.join(moduleMessagesDirPath, 'package.json'))).name);
195 if (!packageName) {
196 throw new kit_1.NamedError('MissingPackageName', errMessage);
197 }
198 }
199 catch (err) {
200 throw new kit_1.NamedError('MissingPackageName', errMessage, err);
201 }
202 }
203 moduleMessagesDirPath += `${path.sep}messages`;
204 for (const file of fs.readdirSync(moduleMessagesDirPath)) {
205 const filePath = path.join(moduleMessagesDirPath, file);
206 const stat = fs.statSync(filePath);
207 if (stat) {
208 if (stat.isDirectory()) {
209 // When we support other locales, load them from /messages/<local>/<bundleName>.json
210 // Change generateFileLoaderFunction to handle loading locales.
211 }
212 else if (stat.isFile()) {
213 this.importMessageFile(packageName, filePath);
214 }
215 }
216 }
217 }
218 /**
219 * Load messages for a given package and bundle. If the bundle is not already cached, use the loader function
220 * created from {@link Messages.setLoaderFunction} or {@link Messages.importMessagesDirectory}.
221 *
222 * @param packageName The name of the npm package.
223 * @param bundleName The name of the bundle to load.
224 */
225 static loadMessages(packageName, bundleName) {
226 const key = new Key(packageName, bundleName);
227 let messages;
228 if (this.isCached(packageName, bundleName)) {
229 messages = this.bundles.get(key.toString());
230 }
231 else if (this.loaders.has(key.toString())) {
232 const loader = this.loaders.get(key.toString());
233 if (loader) {
234 messages = loader(Messages.getLocale());
235 this.bundles.set(key.toString(), messages);
236 messages = this.bundles.get(key.toString());
237 }
238 }
239 if (messages) {
240 return messages;
241 }
242 // Don't use messages inside messages
243 throw new kit_1.NamedError('MissingBundleError', `Missing bundle ${key.toString()} for locale ${Messages.getLocale()}.`);
244 }
245 /**
246 * Check if a bundle already been loaded.
247 * @param packageName The npm package name.
248 * @param bundleName The bundle name.
249 */
250 static isCached(packageName, bundleName) {
251 return this.bundles.has(new Key(packageName, bundleName).toString());
252 }
253 /**
254 * Get a message using a message key and use the tokens as values for tokenization.
255 * @param key The key of the message.
256 * @param tokens The values to substitute in the message.
257 *
258 * **See** https://nodejs.org/api/util.html#util_util_format_format_args
259 */
260 getMessage(key, tokens = []) {
261 return this.getMessageWithMap(key, tokens, this.messages);
262 }
263 getMessageWithMap(key, tokens = [], map) {
264 // Allow nested keys for better grouping
265 const group = key.match(/([a-zA-Z0-9_-]+)\.(.*)/);
266 if (group) {
267 const parentKey = group[1];
268 const childKey = group[2];
269 const childObject = map.get(parentKey);
270 if (childObject && ts_types_1.isAnyJson(childObject)) {
271 const childMap = new Map(Object.entries(childObject));
272 return this.getMessageWithMap(childKey, tokens, childMap);
273 }
274 }
275 if (!map.has(key)) {
276 // Don't use messages inside messages
277 throw new kit_1.NamedError('MissingMessageError', `Missing message ${this.bundleName}:${key} for locale ${Messages.getLocale()}.`);
278 }
279 const msg = ts_types_1.ensureString(map.get(key));
280 return util.format(msg, ...tokens);
281 }
282}
283/**
284 * Internal readFile. Exposed for unit testing. Do not use util/fs.readFile as messages.js
285 * should have no internal dependencies.
286 * @param filePath read file target.
287 * @ignore
288 */
289Messages._readFile = (filePath) => {
290 return require(filePath);
291};
292// It would be AWESOME to use Map<Key, Message> but js does an object instance comparison and doesn't let you
293// override valueOf or equals for the === operator, which map uses. So, Use Map<String, Message>
294// A map of loading functions to dynamically load messages when they need to be used
295Messages.loaders = new Map();
296// A map cache of messages bundles that have already been loaded
297Messages.bundles = new Map();
298exports.Messages = Messages;
299//# sourceMappingURL=messages.js.map
\No newline at end of file