Skip to content

Commit dc2ad01

Browse files
committed
Extract translations from extension class
1 parent 22e0bae commit dc2ad01

File tree

10 files changed

+193
-149
lines changed

10 files changed

+193
-149
lines changed

src/extension/Extension.ts

Lines changed: 8 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
import * as zip from "@zip.js/zip.js";
22
import prettyBytes from "pretty-bytes";
3-
import { sum } from "../utilities/iterators";
43
import { createFileSystem } from "./FileSystem";
5-
import { FSFolder } from "./FileSystem";
4+
import type { FSFolder } from "./FileSystem";
5+
import Translations from "./modules/Translations";
66
import type { ExtensionSummary } from "./types/ExtensionSummary";
77
import type { Manifest } from "./types/Manifest";
8-
import type { Translations } from "./types/Translations";
98

109
export default class Extension {
1110
readonly manifest: Readonly<Manifest>;
1211
readonly files: FSFolder;
13-
readonly #translations: Map<string, Translations>;
12+
readonly translations: Translations;
1413

15-
private constructor(root: FSFolder, manifest: Manifest, translations: Map<string, Translations>) {
14+
private constructor(root: FSFolder, manifest: Manifest, translations: Translations) {
1615
/* Some of the initialization happens in the static create method because the constructor is not async. */
1716

1817
this.files = root;
1918
this.manifest = Object.freeze(manifest);
20-
this.#translations = translations;
19+
this.translations = translations;
2120
}
2221

2322
/**
@@ -35,57 +34,11 @@ export default class Extension {
3534
const manifest = JSON.parse(rawManifest.replace(/^\/\/.+$/gm, "")) as Manifest;
3635

3736
// Translations
38-
const translations = new Map<string, Translations>();
39-
if (manifest.default_locale !== undefined) {
40-
// default_locale must be present if the _locales subdirectory is present, must be absent otherwise.
41-
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/default_locale
42-
43-
await Promise.all(
44-
Array.from(files.getFolder("_locales")!.children.values())
45-
.filter((node) => node instanceof FSFolder)
46-
.filter((folder) => folder.getFile("messages.json", false))
47-
.map(async (folder) => {
48-
const rawFileContent = await folder.getFile("messages.json").text();
49-
translations.set(folder.name, JSON.parse(rawFileContent));
50-
})
51-
);
52-
53-
if (!translations.has(manifest.default_locale)) {
54-
console.warn(`Default locale (${manifest.default_locale}) missing.`);
55-
}
56-
}
37+
const translations = await Translations.create(manifest, files);
5738

5839
return new Extension(files, manifest, translations);
5940
}
6041

61-
i18n(
62-
messageName: string,
63-
options: Partial<{ substitutions: string | string[]; locale: string }> = {}
64-
): string {
65-
const { substitutions = [], locale = this.manifest.default_locale } = options;
66-
67-
if (!locale) {
68-
return messageName;
69-
}
70-
71-
const translations = this.#translations.get(locale);
72-
if (!translations) {
73-
return messageName;
74-
}
75-
76-
const translation = translations[messageName];
77-
78-
if (translation !== undefined) {
79-
if (substitutions.length > 0) {
80-
// FIXME
81-
console.warn("Substitutions currently not supported.");
82-
}
83-
return translation.message;
84-
}
85-
86-
return messageName;
87-
}
88-
8942
/**
9043
* Get a blob: URL for the given file.
9144
* If timeout is a positive number the URL will be revoked automatically.
@@ -120,44 +73,13 @@ export default class Extension {
12073
};
12174
}
12275

123-
/**
124-
* Get the translated messages for the given locale and some meta information including:
125-
* - messages (keys) that are missing (not translated) in this locale
126-
* - the percentage of translated messages in this locale
127-
*/
128-
getTranslations(locale: string): TranslationsInfo | undefined {
129-
let actualLocale = locale;
130-
let messages = this.#translations.get(actualLocale);
131-
132-
if (!messages && locale.includes("-")) {
133-
// If the requested locale is en-US we can use en as a fallback.
134-
actualLocale = locale.split("-")[0];
135-
messages = this.#translations.get(actualLocale);
136-
}
137-
138-
if (!messages) {
139-
return undefined;
140-
}
141-
142-
const allMessageKeys = new Set(this.#translations.values().flatMap((t) => Object.keys(t)));
143-
const translatedKeys = new Set(Object.keys(messages));
144-
const missingKeys = allMessageKeys.difference(translatedKeys);
145-
146-
return {
147-
percentage: translatedKeys.size / allMessageKeys.size,
148-
missingKeys: missingKeys,
149-
messages: messages,
150-
locale: actualLocale
151-
};
152-
}
153-
15476
getLocales() {
155-
return [...this.#translations.keys()];
77+
return this.translations.getLocales();
15678
}
15779

15880
get meta(): Omit<ExtensionSummary["meta"], "icon"> {
15981
return {
160-
name: this.#__MSG_i18n(this.manifest.name),
82+
name: this.translations.i18nForManifestKey(this.manifest.name),
16183
version: this.manifest.version,
16284
source: "file", // FIXME
16385
author: this.#getAuthor(),
@@ -166,47 +88,6 @@ export default class Extension {
16688
};
16789
}
16890

169-
get translationInfo(): ExtensionSummary["translations"] {
170-
const messageKeys = new Set(this.#translations.values().flatMap((t) => Object.keys(t))).size;
171-
const translatedMessages = sum(this.#translations.values().map((t) => Object.keys(t).length));
172-
173-
return {
174-
locales: this.getLocales(),
175-
messages: messageKeys,
176-
defaultLocale: this.manifest.default_locale,
177-
percentage:
178-
this.#translations.size * messageKeys > 0
179-
? translatedMessages / (this.#translations.size * messageKeys)
180-
: undefined
181-
};
182-
}
183-
184-
/**
185-
* Get translations of localized manifest strings.
186-
* https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization#internationalizing_manifest.json
187-
*/
188-
#__MSG_i18n(rawString: string, locale = this.manifest.default_locale): string {
189-
if (!locale || !this.#translations.has(locale)) {
190-
return rawString;
191-
}
192-
193-
const matches = rawString.match(/^__MSG_(.+)__$/);
194-
195-
if (matches === null || matches.length !== 2) {
196-
return rawString;
197-
}
198-
199-
const messageName = matches[1];
200-
201-
const translation = this.#translations.get(locale)![messageName];
202-
203-
if (translation !== undefined) {
204-
return translation.message;
205-
}
206-
207-
return rawString;
208-
}
209-
21091
#getAuthor(): string | undefined {
21192
const author = this.manifest.author;
21293

@@ -242,10 +123,3 @@ export type PermissionsInfo = {
242123
optional: string[];
243124
};
244125
};
245-
246-
export type TranslationsInfo = {
247-
percentage: number;
248-
missingKeys: Set<string>;
249-
messages: Translations;
250-
locale: string;
251-
};
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { sum } from "../../utilities/iterators";
2+
import { FSFolder } from "../FileSystem";
3+
import type { ExtensionSummary } from "../types/ExtensionSummary";
4+
import type { Manifest } from "../types/Manifest";
5+
6+
export default class Translations {
7+
readonly default_locale: Manifest["default_locale"];
8+
readonly #data: Map<LocaleId, LocaleData>;
9+
10+
private constructor(default_locale: Manifest["default_locale"], data: Map<LocaleId, LocaleData>) {
11+
this.default_locale = default_locale;
12+
this.#data = data;
13+
}
14+
15+
static async create(manifest: Manifest, files: FSFolder): Promise<Translations> {
16+
const data = new Map<LocaleId, LocaleData>();
17+
18+
if (manifest.default_locale !== undefined) {
19+
// default_locale must be present if the _locales subdirectory is present, must be absent otherwise.
20+
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/default_locale
21+
22+
await Promise.all(
23+
Array.from(files.getFolder("_locales")!.children.values())
24+
.filter((node) => node instanceof FSFolder)
25+
.filter((folder) => folder.getFile("messages.json", false))
26+
.map(async (folder) => {
27+
const rawFileContent = await folder.getFile("messages.json").text();
28+
data.set(folder.name, JSON.parse(rawFileContent));
29+
})
30+
);
31+
32+
if (!data.has(manifest.default_locale)) {
33+
console.warn(`Default locale (${manifest.default_locale}) missing.`);
34+
}
35+
}
36+
37+
return new Translations(manifest.default_locale, data);
38+
}
39+
40+
i18n(
41+
messageName: string,
42+
options: Partial<{ substitutions: string | string[]; locale: string }> = {}
43+
): string {
44+
const { substitutions = [], locale = this.default_locale } = options;
45+
46+
if (!locale) {
47+
return messageName;
48+
}
49+
50+
const translations = this.#data.get(locale);
51+
if (!translations) {
52+
return messageName;
53+
}
54+
55+
const translation = translations[messageName];
56+
57+
if (translation !== undefined) {
58+
if (substitutions.length > 0) {
59+
// FIXME
60+
console.warn("Substitutions currently not supported.");
61+
}
62+
return translation.message;
63+
}
64+
65+
return messageName;
66+
}
67+
68+
/**
69+
* Get translations of localized manifest strings.
70+
* https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization#internationalizing_manifest.json
71+
*/
72+
i18nForManifestKey(rawString: string, locale = this.default_locale): string {
73+
if (!locale || !this.#data.has(locale)) {
74+
return rawString;
75+
}
76+
77+
const matches = rawString.match(/^__MSG_(.+)__$/);
78+
79+
if (matches === null || matches.length !== 2) {
80+
return rawString;
81+
}
82+
83+
const messageName = matches[1];
84+
85+
const translation = this.#data.get(locale)![messageName];
86+
87+
if (translation !== undefined) {
88+
return translation.message;
89+
}
90+
91+
return rawString;
92+
}
93+
94+
getLocales() {
95+
return [...this.#data.keys()];
96+
}
97+
98+
/**
99+
* Get the translated messages for the given locale and some meta information including:
100+
* - messages (keys) that are missing (not translated) in this locale
101+
* - the percentage of translated messages in this locale
102+
*/
103+
getLocaleData(locale: string): LocaleInfo | undefined {
104+
let actualLocale = locale;
105+
let messages = this.#data.get(actualLocale);
106+
107+
if (!messages && locale.includes("-")) {
108+
// If the requested locale is en-US we can use en as a fallback.
109+
actualLocale = locale.split("-")[0];
110+
messages = this.#data.get(actualLocale);
111+
}
112+
113+
if (!messages) {
114+
return undefined;
115+
}
116+
117+
const allMessageKeys = new Set(this.#data.values().flatMap((t) => Object.keys(t)));
118+
const translatedKeys = new Set(Object.keys(messages));
119+
const missingKeys = allMessageKeys.difference(translatedKeys);
120+
121+
return {
122+
percentage: translatedKeys.size / allMessageKeys.size,
123+
missingKeys: missingKeys,
124+
messages: messages,
125+
locale: actualLocale
126+
};
127+
}
128+
129+
getSummary(): ExtensionSummary["translations"] {
130+
const messageKeys = new Set(this.#data.values().flatMap((t) => Object.keys(t))).size;
131+
const translatedMessages = sum(this.#data.values().map((t) => Object.keys(t).length));
132+
133+
return {
134+
locales: this.getLocales(),
135+
messages: messageKeys,
136+
defaultLocale: this.default_locale,
137+
percentage:
138+
this.#data.size * messageKeys > 0
139+
? translatedMessages / (this.#data.size * messageKeys)
140+
: undefined
141+
};
142+
}
143+
}
144+
145+
type LocaleId = string;
146+
147+
/**
148+
* Describes the i18n translations for a single locale.
149+
* See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization for details.
150+
*/
151+
export type LocaleData = Record<
152+
string,
153+
{
154+
message: string;
155+
description?: string;
156+
placeholders?: Record<
157+
string,
158+
{
159+
content: string;
160+
example?: string;
161+
}
162+
>;
163+
}
164+
>;
165+
166+
export type LocaleInfo = {
167+
percentage: number;
168+
missingKeys: Set<string>;
169+
messages: LocaleData;
170+
locale: string;
171+
};

0 commit comments

Comments
 (0)