l10n.js (8581B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 "use strict"; 5 6 const parsePropertiesFile = require("devtools/shared/node-properties/node-properties"); 7 const { sprintf } = require("devtools/shared/sprintfjs/sprintf"); 8 9 const propertiesMap = {}; 10 11 // Map used to memoize Number formatters. 12 const numberFormatters = new Map(); 13 const getNumberFormatter = function (decimals) { 14 let formatter = numberFormatters.get(decimals); 15 if (!formatter) { 16 // Create and memoize a formatter for the provided decimals 17 formatter = Intl.NumberFormat(undefined, { 18 maximumFractionDigits: decimals, 19 minimumFractionDigits: decimals, 20 }); 21 numberFormatters.set(decimals, formatter); 22 } 23 24 return formatter; 25 }; 26 27 /** 28 * Memoized getter for properties files that ensures a given url is only required and 29 * parsed once. 30 * 31 * @param {string} url 32 * The URL of the properties file to parse. 33 * @return {object} parsed properties mapped in an object. 34 */ 35 function getProperties(url) { 36 if (!propertiesMap[url]) { 37 let propertiesFile; 38 let isNodeEnv = false; 39 try { 40 // eslint-disable-next-line no-undef 41 isNodeEnv = process?.release?.name == "node"; 42 } catch (e) {} 43 44 if (isNodeEnv) { 45 // In Node environment (e.g. when running jest test), we need to prepend the en-US 46 // to the filename in order to have the actual location of the file in source. 47 const lastDelimIndex = url.lastIndexOf("/"); 48 const defaultLocaleUrl = 49 url.substring(0, lastDelimIndex) + 50 "/en-US" + 51 url.substring(lastDelimIndex); 52 53 const path = require("path"); 54 // eslint-disable-next-line no-undef 55 const rootPath = path.join(__dirname, "../../"); 56 const absoluteUrl = path.join(rootPath, defaultLocaleUrl); 57 const { readFileSync } = require("fs"); 58 // In Node environment we directly use readFileSync to get the file content instead 59 // of relying on custom raw loader, like we do in regular environment. 60 propertiesFile = readFileSync(absoluteUrl, { encoding: "utf8" }); 61 } else { 62 propertiesFile = require("raw!" + url); 63 } 64 65 propertiesMap[url] = parsePropertiesFile(propertiesFile); 66 } 67 68 return propertiesMap[url]; 69 } 70 71 /** 72 * Localization convenience methods. 73 */ 74 class LocalizationHelper { 75 /** 76 * 77 * @param {string} stringBundleName 78 * The desired string bundle's name. 79 * @param {boolean} strict 80 * (legacy) pass true to force the helper to throw if the l10n id cannot be found. 81 */ 82 constructor(stringBundleName, strict = false) { 83 this.stringBundleName = stringBundleName; 84 this.strict = strict; 85 } 86 /** 87 * L10N shortcut function. 88 * 89 * @param string name 90 * @return string 91 */ 92 getStr(name) { 93 const properties = getProperties(this.stringBundleName); 94 if (name in properties) { 95 return properties[name]; 96 } 97 98 if (this.strict) { 99 throw new Error("No localization found for [" + name + "]"); 100 } 101 102 console.error("No localization found for [" + name + "]"); 103 return name; 104 } 105 106 /** 107 * L10N shortcut function. 108 * 109 * @param string name 110 * @param array args 111 * @return string 112 */ 113 getFormatStr(name, ...args) { 114 return sprintf(this.getStr(name), ...args); 115 } 116 117 /** 118 * L10N shortcut function for numeric arguments that need to be formatted. 119 * All numeric arguments will be fixed to 2 decimals and given a localized 120 * decimal separator. Other arguments will be left alone. 121 * 122 * @param string name 123 * @param array args 124 * @return string 125 */ 126 getFormatStrWithNumbers(name, ...args) { 127 const newArgs = args.map(x => { 128 return typeof x == "number" ? this.numberWithDecimals(x, 2) : x; 129 }); 130 131 return this.getFormatStr(name, ...newArgs); 132 } 133 134 /** 135 * Converts a number to a locale-aware string format and keeps a certain 136 * number of decimals. 137 * 138 * @param number number 139 * The number to convert. 140 * @param number decimals [optional] 141 * Total decimals to keep. 142 * @return string 143 * The localized number as a string. 144 */ 145 numberWithDecimals(number, decimals = 0) { 146 // Do not show decimals for integers. 147 if (number === (number | 0)) { 148 return getNumberFormatter(0).format(number); 149 } 150 151 // If this isn't a number (and yes, `isNaN(null)` is false), return zero. 152 if (isNaN(number) || number === null) { 153 return getNumberFormatter(0).format(0); 154 } 155 156 // Localize the number using a memoized Intl.NumberFormat formatter. 157 const localized = getNumberFormatter(decimals).format(number); 158 159 // Convert the localized number to a number again. 160 const localizedNumber = localized * 1; 161 // Check if this number is now equal to an integer. 162 if (localizedNumber === (localizedNumber | 0)) { 163 // If it is, remove the fraction part. 164 return getNumberFormatter(0).format(localizedNumber); 165 } 166 167 return localized; 168 } 169 } 170 171 function getPropertiesForNode(node) { 172 const bundleEl = node.closest("[data-localization-bundle]"); 173 if (!bundleEl) { 174 return null; 175 } 176 177 const propertiesUrl = bundleEl.getAttribute("data-localization-bundle"); 178 return getProperties(propertiesUrl); 179 } 180 181 /** 182 * Translate existing markup annotated with data-localization attributes. 183 * 184 * How to use data-localization in markup: 185 * 186 * <div data-localization="content=myContent;title=myTitle"/> 187 * 188 * The data-localization attribute identifies an element as being localizable. 189 * The content of the attribute is semi-colon separated list of descriptors. 190 * - "title=myTitle" means the "title" attribute should be replaced with the localized 191 * string corresponding to the key "myTitle". 192 * - "content=myContent" means the text content of the node should be replaced by the 193 * string corresponding to "myContent" 194 * 195 * How to define the localization bundle in markup: 196 * 197 * <div data-localization-bundle="url/to/my.properties"> 198 * [...] 199 * <div data-localization="content=myContent;title=myTitle"/> 200 * 201 * Set the data-localization-bundle on an ancestor of the nodes that should be localized. 202 * 203 * @param {Element} root 204 * The root node to use for the localization 205 */ 206 function localizeMarkup(root) { 207 const elements = root.querySelectorAll("[data-localization]"); 208 for (const element of elements) { 209 const properties = getPropertiesForNode(element); 210 if (!properties) { 211 continue; 212 } 213 214 const attributes = element.getAttribute("data-localization").split(";"); 215 for (const attribute of attributes) { 216 const [name, value] = attribute.trim().split("="); 217 if (name === "content") { 218 element.textContent = properties[value]; 219 } else { 220 element.setAttribute(name, properties[value]); 221 } 222 } 223 224 element.removeAttribute("data-localization"); 225 } 226 } 227 228 /** 229 * A helper for having the same interface as LocalizationHelper, but for more 230 * than one file. Useful for abstracting l10n string locations. 231 */ 232 class MultiLocalizationHelper { 233 constructor(...stringBundleNames) { 234 const instances = stringBundleNames.map(bundle => { 235 // Use strict = true because the MultiLocalizationHelper logic relies on try/catch 236 // around the underlying LocalizationHelper APIs. 237 return new LocalizationHelper(bundle, true); 238 }); 239 240 // Get all function members of the LocalizationHelper class, making sure we're 241 // not executing any potential getters while doing so, and wrap all the 242 // methods we've found to work on all given string bundles. 243 Object.getOwnPropertyNames(LocalizationHelper.prototype) 244 .map(name => ({ 245 name, 246 descriptor: Object.getOwnPropertyDescriptor( 247 LocalizationHelper.prototype, 248 name 249 ), 250 })) 251 .filter(({ descriptor }) => descriptor.value instanceof Function) 252 .forEach(method => { 253 this[method.name] = (...args) => { 254 for (const l10n of instances) { 255 try { 256 return method.descriptor.value.apply(l10n, args); 257 } catch (e) { 258 // Do nothing 259 } 260 } 261 return null; 262 }; 263 }); 264 } 265 } 266 267 exports.LocalizationHelper = LocalizationHelper; 268 exports.localizeMarkup = localizeMarkup; 269 exports.MultiLocalizationHelper = MultiLocalizationHelper; 270 Object.defineProperty(exports, "ELLIPSIS", { 271 get: () => 272 typeof Services == "undefined" ? "\u2026" : Services.locale.ellipsis, 273 });