1 | ;
|
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 | */
|
13 | Object.defineProperty(exports, "__esModule", { value: true });
|
14 | const kit_1 = require("@salesforce/kit");
|
15 | const ts_types_1 = require("@salesforce/ts-types");
|
16 | const fs = require("fs");
|
17 | const path = require("path");
|
18 | const util = require("util");
|
19 | class 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 | */
|
70 | class 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 | */
|
289 | Messages._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
|
295 | Messages.loaders = new Map();
|
296 | // A map cache of messages bundles that have already been loaded
|
297 | Messages.bundles = new Map();
|
298 | exports.Messages = Messages;
|
299 | //# sourceMappingURL=messages.js.map |
\ | No newline at end of file |