-
Notifications
You must be signed in to change notification settings - Fork 1
/
Localizer.js
293 lines (253 loc) · 10.1 KB
/
Localizer.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
/**
* Translates WebExtension's HTML document by attributes.
*
* @public
* @module Localizer
* @requires ./replaceInnerContent
*/
import { replaceInnerContent } from "./replaceInnerContent.js";
const I18N_ATTRIBUTE = "data-i18n";
const I18N_DATASET = "i18n";
const I18N_DATASET_INT = I18N_DATASET.length;
const I18N_DATASET_KEEP_CHILDREN = "optI18nKeepChildren";
const UNIQUE_REPLACEMENT_SPLIT = "$i18nSplit$";
const UNIQUE_REPLACEMENT_ID = "i18nKeepChildren#";
/**
* Splits the _MSG__*__ format and returns the actual tag.
*
* The format is defined in {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/i18n/Locale-Specific_Message_reference#name}.
*
* @private
* @param {string} tag
* @returns {string}
* @throws {Error} if pattern does not match
*/
function getMessageTag(tag) {
/** {@link https://regex101.com/r/LAC5Ib/2} **/
const splitMessage = tag.split(/^__MSG_([\w@]+)__$/);
// throw custom exception if input is invalid
if (splitMessage.length < 2) {
throw new Error(`invalid message tag pattern "${tag}"`);
}
return splitMessage[1];
}
/**
* Converts a dataset value back to a real attribute.
*
* This is intended for substrings of datasets too, i.e. it does not add the "data" prefix
* in front of the attribute.
*
* @private
* @param {string} dataSetValue
* @returns {string}
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset#Name_conversion}
*/
function convertDatasetToAttribute(dataSetValue) {
// if beginning of string is capital letter, only lowercase that
/** {@link https://regex101.com/r/GaVoVi/1} **/
dataSetValue = dataSetValue.replace(/^[A-Z]/, (char) => char.toLowerCase());
// replace all other capital letters with dash in front of them
/** {@link https://regex101.com/r/GaVoVi/3} **/
dataSetValue = dataSetValue.replace(/[A-Z]/, (char) => {
return `-${char.toLowerCase()}`;
});
return dataSetValue;
}
/**
* Returns the translated message when a key is given.
*
* @private
* @param {string} messageName
* @param {string[]} substitutions
* @returns {string} translated string
* @throws {Error} if no translation could be found
* @see {@link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/i18n/getMessage}
*/
function getTranslatedMessage(messageName, substitutions) {
const translatedMessage = browser.i18n.getMessage(messageName, substitutions);
if (!translatedMessage) {
throw new Error(`no translation string for "${messageName}" could be found`);
}
return translatedMessage;
}
/**
* Translates only the text nodes of the element, and adjusts the psition of the
* other HTML elements.
*
* It does this wout inserting HTML just by moving DOM elements and inserting text,
* so it works around potential security problems of innerHtml etc.
*
* @private
* @param {HTMLElement} parent the element to tramslate
* @param {string} translatedMessage the already translated and prepared string
* @param {Object} subsContainer
* @param {string[]} subsContainer.substitutions IDs correspond to number of htmlOnlyChilds
* @param {Node[]} subsContainer.textOnlyChilds
* @param {HTMLElement[]} subsContainer.htmlOnlyChilds
* @param {Node[]} [subsContainer.allChilds] not actually used currently
* @returns {void}
*/
function innerTranslateTextNodes(parent, translatedMessage, subsContainer) {
const splitTranslatedMessage = translatedMessage.split(UNIQUE_REPLACEMENT_SPLIT);
console.log("Replacing text nodes for", parent, ", message:", translatedMessage, "detected elements:", subsContainer);
// sanity check whether all translations were used
// We also trigger for =, because we assume we have at least one text node, which
// is also returned in splitTranslatedMessage
if (splitTranslatedMessage.length <= subsContainer.substitutions.length) {
console.warn(
"You used only", splitTranslatedMessage.length, "message blocks, although you could use",
subsContainer.substitutions.length, "substitutions. Possibly you did not include all substitutions in your translation?",
"Check for typos in the placeholder name e.g.",
{
translationObject: splitTranslatedMessage,
intendedSubstitutions: subsContainer.substitutions
}
);
}
// create iterator out of arrays
const textOnlyIterator = subsContainer.textOnlyChilds[Symbol.iterator]();
// for first element, fake the first element as the next element
let previousElement = { nextSibling: parent.firstChild };
for (const message of splitTranslatedMessage) {
// if it is placeholder, replace it with HTML element
if (message.startsWith(UNIQUE_REPLACEMENT_ID)) {
const childId = message.slice(UNIQUE_REPLACEMENT_ID.length);
const child = subsContainer.htmlOnlyChilds[childId - 1];
// move child element in there, *after* the last element = before the next one
const newElement = parent.insertBefore(child, previousElement.nextSibling);
// save last element
previousElement = newElement;
} else {
// otherwise we have a text message, which we need to put into a
// text node
const nextText = textOnlyIterator.next();
const nextTextElement = nextText.value;
// if we have no more text elements
if (nextText.done) {
console.warn("Translation contained more text than HTML template. We now add a note. Triggered for translation: ", message);
// just create & add a new one
const newTextNode = document.createTextNode(message);
// move child element in there, *after* the last element = before the next one
parent.insertBefore(newTextNode, previousElement.nextSibling);
// save last element
previousElement = nextTextElement;
} else {
// replace the next text element
nextTextElement.textContent = message;
// save last element
previousElement = nextTextElement;
}
}
}
}
/**
* Replaces attribute or inner text of element with string.
*
* @private
* @param {HTMLElement} elem
* @param {string} attribute attribute to replace, set to "null" to replace inner content
* @param {string} translatedMessage
* @returns {void}
*/
function replaceWith(elem, attribute, translatedMessage) {
const isHTML = translatedMessage.startsWith("!HTML!");
if (isHTML) {
translatedMessage = translatedMessage.replace("!HTML!", "").trimLeft();
}
switch (attribute) {
case null:
replaceInnerContent(elem, translatedMessage, isHTML);
break;
default:
// attributes are never allowed to contain unbescaped HTML
elem.setAttribute(attribute, translatedMessage);
}
}
/**
* Returns the HTML children..
*
* @private
* @param {HTMLElement} elem
* @returns {void}
*/
function getSubitems(elem) {
// only keep subitems if enabled
if (!(I18N_DATASET_KEEP_CHILDREN in elem.dataset)) {
return {};
}
// always creates arrays to freeze elements, so later DOM changes do not
// affect it
// get all children elements
const childs = Array.from(elem.childNodes);
// filter out text childs
const htmlOnlyChilds = Array.from(elem.children);
const textOnlyChilds = childs.filter((node) => node.nodeType === Node.TEXT_NODE);
// create list of substitutions, i.e. $1, $2, §3 etc.
const substitutions = htmlOnlyChilds.map((elem, num) => `${UNIQUE_REPLACEMENT_SPLIT}${UNIQUE_REPLACEMENT_ID}${num + 1}${UNIQUE_REPLACEMENT_SPLIT}`);
return {
substitutions: substitutions,
allChilds: childs,
textOnlyChilds: textOnlyChilds,
htmlOnlyChilds: htmlOnlyChilds
};
}
/**
* Localises the strings to localize in the HTMLElement.
*
* @private
* @param {HTMLElement} elem
* @param {string} tag the translation tag
* @returns {void}
*/
function replaceI18n(elem, tag) {
const subsContainer = getSubitems(elem);
// localize main content
if (tag !== "") {
try {
const translatedMessage = getTranslatedMessage(getMessageTag(tag), subsContainer.substitutions);
// if we have substrings to replace
if (subsContainer.substitutions) {
innerTranslateTextNodes(elem, translatedMessage, subsContainer);
} else {
// otherwise do "usual" full replacement
replaceWith(elem, null, translatedMessage);
}
} catch (error) {
// log error but continue translating as it was likely just one problem in one translation
console.error(error.message, "for element", elem);
}
}
// replace attributes
for (const [dataAttribute, dataValue] of Object.entries(elem.dataset)) {
if (
!dataAttribute.startsWith(I18N_DATASET) || // ignore other data attributes
dataAttribute.length === I18N_DATASET_INT // ignore non-attribute replacements
) {
continue;
}
const replaceAttribute = convertDatasetToAttribute(dataAttribute.slice(I18N_DATASET_INT));
try {
const translatedMessage = getTranslatedMessage(getMessageTag(dataValue));
replaceWith(elem, replaceAttribute, translatedMessage);
} catch (error) {
// log error but continue translating as it was likely just one problem in one translation
console.error(error.message, "for element", elem, "while replacing attribute", replaceAttribute);
}
}
}
/**
* Localizes static strings in the HTML file.
*
* @public
* @returns {void}
*/
export function init() {
document.querySelectorAll(`[${I18N_ATTRIBUTE}]`).forEach((currentElem) => {
const contentString = currentElem.dataset[I18N_DATASET];
replaceI18n(currentElem, contentString);
});
// replace html lang attribut after translation
document.querySelector("html").setAttribute("lang", browser.i18n.getUILanguage());
}
// automatically init module
init();