diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-03-30 01:36:22 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-05-16 00:54:57 +0200 |
commit | 7af01713180066b6dc1061dae930840e48c60fec (patch) | |
tree | 604a52eb8f167caf3400dad7589aaa5be2d06c8d | |
parent | feat: Add custom menubar (diff) | |
download | sophie-7af01713180066b6dc1061dae930840e48c60fec.tar.gz sophie-7af01713180066b6dc1061dae930840e48c60fec.tar.zst sophie-7af01713180066b6dc1061dae930840e48c60fec.zip |
feat(main): Add localization support
Add i18next with a custom backend to the main process to load
localization from file.
Missing localizations are written to a missing localizations file in
debug mode, but silently fall back in production mode.
We will also need to add a custom backend for the renderer process that
communicates with the main process.
(i18next-fs-electron-backend is not applicable here, because we need
localizations both in the main and renderer processes.)
Signed-off-by: Kristóf Marussy <kristof@marussy.com>
-rw-r--r-- | .electron-builder.config.cjs | 1 | ||||
-rw-r--r-- | locales/en/translation.json | 20 | ||||
-rw-r--r-- | package.json | 5 | ||||
-rw-r--r-- | packages/main/package.json | 1 | ||||
-rw-r--r-- | packages/main/src/i18n/I18nStore.ts | 104 | ||||
-rw-r--r-- | packages/main/src/i18n/LocalizationRepository.ts | 39 | ||||
-rw-r--r-- | packages/main/src/i18n/RepositoryBasedI18nBackend.ts | 224 | ||||
-rw-r--r-- | packages/main/src/i18n/i18nLog.ts | 41 | ||||
-rw-r--r-- | packages/main/src/i18n/impl/LocaltizationFiles.ts | 93 | ||||
-rw-r--r-- | packages/main/src/i18n/loadLocalization.ts | 50 | ||||
-rw-r--r-- | packages/main/src/infrastructure/config/impl/ConfigFile.ts | 5 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts | 29 | ||||
-rw-r--r-- | packages/main/src/infrastructure/resources/Resources.ts | 2 | ||||
-rw-r--r-- | packages/main/src/infrastructure/resources/impl/getDistResources.ts | 4 | ||||
-rw-r--r-- | packages/main/src/initReactions.ts | 8 | ||||
-rw-r--r-- | packages/main/src/stores/MainStore.ts | 9 | ||||
-rw-r--r-- | packages/main/src/utils/isErrno.ts | 28 | ||||
-rw-r--r-- | yarn.lock | 133 |
18 files changed, 666 insertions, 130 deletions
diff --git a/.electron-builder.config.cjs b/.electron-builder.config.cjs index aa7d9e0..4340623 100644 --- a/.electron-builder.config.cjs +++ b/.electron-builder.config.cjs | |||
@@ -17,6 +17,7 @@ const config = { | |||
17 | 'packages/renderer/dist/**', | 17 | 'packages/renderer/dist/**', |
18 | 'packages/service-inject/dist/**', | 18 | 'packages/service-inject/dist/**', |
19 | 'packages/service-preload/dist/**', | 19 | 'packages/service-preload/dist/**', |
20 | 'locales/**', | ||
20 | // Do not ship with source maps. | 21 | // Do not ship with source maps. |
21 | '!**/*.map', | 22 | '!**/*.map', |
22 | ], | 23 | ], |
diff --git a/locales/en/translation.json b/locales/en/translation.json new file mode 100644 index 0000000..2cd7959 --- /dev/null +++ b/locales/en/translation.json | |||
@@ -0,0 +1,20 @@ | |||
1 | { | ||
2 | "menu": { | ||
3 | "servicesMenu": "Services", | ||
4 | "view": { | ||
5 | "showLocationBar": "Show Location Bar", | ||
6 | "reload": "Reload", | ||
7 | "forceReload": "Force Reload", | ||
8 | "toggleDeveloperTools": "Toggle Developer Tools", | ||
9 | "reloadSophie": "Reload Sophie", | ||
10 | "toggleSophieDeveloperTools": "Toggle Sophie Developer Tools" | ||
11 | }, | ||
12 | "services": { | ||
13 | "nextService": "Next Service", | ||
14 | "previousService": "Previous Service" | ||
15 | }, | ||
16 | "help": { | ||
17 | "gitlab": "Gitlab" | ||
18 | } | ||
19 | } | ||
20 | } | ||
diff --git a/package.json b/package.json index fcba86e..7f0d9f9 100644 --- a/package.json +++ b/package.json | |||
@@ -94,8 +94,5 @@ | |||
94 | "typescript": "^4.5.5", | 94 | "typescript": "^4.5.5", |
95 | "vite": "^2.8.4" | 95 | "vite": "^2.8.4" |
96 | }, | 96 | }, |
97 | "packageManager": "yarn@3.1.1", | 97 | "packageManager": "yarn@3.1.1" |
98 | "dependencies": { | ||
99 | "preload": "^0.1.0" | ||
100 | } | ||
101 | } | 98 | } |
diff --git a/packages/main/package.json b/packages/main/package.json index 4ba6d06..85f9b12 100644 --- a/packages/main/package.json +++ b/packages/main/package.json | |||
@@ -14,6 +14,7 @@ | |||
14 | "deep-equal": "^2.0.5", | 14 | "deep-equal": "^2.0.5", |
15 | "electron": "17.1.0", | 15 | "electron": "17.1.0", |
16 | "fs-extra": "^10.0.1", | 16 | "fs-extra": "^10.0.1", |
17 | "i18next": "^21.6.14", | ||
17 | "json5": "^2.2.0", | 18 | "json5": "^2.2.0", |
18 | "lodash-es": "^4.17.21", | 19 | "lodash-es": "^4.17.21", |
19 | "loglevel": "^1.8.0", | 20 | "loglevel": "^1.8.0", |
diff --git a/packages/main/src/i18n/I18nStore.ts b/packages/main/src/i18n/I18nStore.ts new file mode 100644 index 0000000..d833e8a --- /dev/null +++ b/packages/main/src/i18n/I18nStore.ts | |||
@@ -0,0 +1,104 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import type { i18n, TFunction } from 'i18next'; | ||
22 | import { IAtom, createAtom } from 'mobx'; | ||
23 | |||
24 | import { getLogger } from '../utils/log'; | ||
25 | |||
26 | const log = getLogger('I18nStore'); | ||
27 | |||
28 | export type UseTranslationResult = | ||
29 | | { ready: true; i18n: i18n; t: TFunction } | ||
30 | | { ready: false }; | ||
31 | |||
32 | export default class I18nStore { | ||
33 | private readonly languageChangedAtom: IAtom; | ||
34 | |||
35 | private readonly namespaceLoadedAtoms: Map<string, IAtom> = new Map(); | ||
36 | |||
37 | private readonly notifyLanguageChange = () => | ||
38 | this.languageChangedAtom.reportObserved(); | ||
39 | |||
40 | constructor(private readonly i18next: i18n) { | ||
41 | this.languageChangedAtom = createAtom( | ||
42 | 'i18next', | ||
43 | () => i18next.on('languageChanged', this.notifyLanguageChange), | ||
44 | () => i18next.off('languageChanged', this.notifyLanguageChange), | ||
45 | ); | ||
46 | } | ||
47 | |||
48 | useTranslation(ns?: string): UseTranslationResult { | ||
49 | const observed = this.languageChangedAtom.reportObserved(); | ||
50 | const namespaceToLoad = | ||
51 | ns ?? this.i18next.options.defaultNS ?? 'translation'; | ||
52 | if ( | ||
53 | this.i18next.isInitialized && | ||
54 | this.i18next.hasLoadedNamespace(namespaceToLoad) | ||
55 | ) { | ||
56 | return { | ||
57 | ready: true, | ||
58 | i18n: this.i18next, | ||
59 | // eslint-disable-next-line unicorn/no-null -- `i18next` API requires `null`. | ||
60 | t: this.i18next.getFixedT(null, namespaceToLoad), | ||
61 | }; | ||
62 | } | ||
63 | if (observed) { | ||
64 | this.loadNamespace(namespaceToLoad); | ||
65 | } | ||
66 | return { ready: false }; | ||
67 | } | ||
68 | |||
69 | private loadNamespace(ns: string): void { | ||
70 | const existingAtom = this.namespaceLoadedAtoms.get(ns); | ||
71 | if (existingAtom !== undefined) { | ||
72 | existingAtom.reportObserved(); | ||
73 | return; | ||
74 | } | ||
75 | const atom = createAtom(`i18next-${ns}`); | ||
76 | this.namespaceLoadedAtoms.set(ns, atom); | ||
77 | atom.reportObserved(); | ||
78 | |||
79 | const loaded = () => { | ||
80 | this.namespaceLoadedAtoms.delete(ns); | ||
81 | atom.reportChanged(); | ||
82 | }; | ||
83 | |||
84 | const loadAsync = async () => { | ||
85 | await this.i18next.loadNamespaces([ns]); | ||
86 | if (this.i18next.isInitialized) { | ||
87 | setImmediate(loaded); | ||
88 | return; | ||
89 | } | ||
90 | const initialized = () => { | ||
91 | setImmediate(() => { | ||
92 | this.i18next.off('initialized', initialized); | ||
93 | loaded(); | ||
94 | }); | ||
95 | }; | ||
96 | this.i18next.on('initialized', initialized); | ||
97 | }; | ||
98 | |||
99 | loadAsync().catch((error) => { | ||
100 | log.error('Failed to load translations for namespace', ns, error); | ||
101 | setImmediate(loaded); | ||
102 | }); | ||
103 | } | ||
104 | } | ||
diff --git a/packages/main/src/i18n/LocalizationRepository.ts b/packages/main/src/i18n/LocalizationRepository.ts new file mode 100644 index 0000000..041449d --- /dev/null +++ b/packages/main/src/i18n/LocalizationRepository.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import type { ResourceKey } from 'i18next'; | ||
22 | |||
23 | export default interface LocatlizationRepository { | ||
24 | getResourceContents( | ||
25 | language: string, | ||
26 | namespace: string, | ||
27 | ): Promise<ResourceKey>; | ||
28 | |||
29 | getMissingResourceContents( | ||
30 | language: string, | ||
31 | namespace: string, | ||
32 | ): Promise<ResourceKey>; | ||
33 | |||
34 | setMissingResourceContents( | ||
35 | language: string, | ||
36 | namespace: string, | ||
37 | data: ResourceKey, | ||
38 | ): Promise<void>; | ||
39 | } | ||
diff --git a/packages/main/src/i18n/RepositoryBasedI18nBackend.ts b/packages/main/src/i18n/RepositoryBasedI18nBackend.ts new file mode 100644 index 0000000..4d87ce9 --- /dev/null +++ b/packages/main/src/i18n/RepositoryBasedI18nBackend.ts | |||
@@ -0,0 +1,224 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import type { | ||
22 | BackendModule, | ||
23 | InitOptions, | ||
24 | ReadCallback, | ||
25 | Services, | ||
26 | } from 'i18next'; | ||
27 | import { debounce } from 'lodash-es'; | ||
28 | import ms from 'ms'; | ||
29 | |||
30 | import { getLogger } from '../utils/log'; | ||
31 | |||
32 | import type LocatlizationRepository from './LocalizationRepository'; | ||
33 | |||
34 | const MISSING_ENTRIES_DEBOUNCE_TIME = ms('1s'); | ||
35 | |||
36 | const log = getLogger('RepositoryBasedI18nBackend'); | ||
37 | |||
38 | /** | ||
39 | * I18next backend for loading translations from a file. | ||
40 | * | ||
41 | * Loosely based on [i18next-fs-backend](https://github.com/i18next/i18next-fs-backend), | ||
42 | * but we shrink bundle size by omitting JSON5 or YAML translation handling. | ||
43 | * Direct file IO is replaced by the `LocalizationRepository`, | ||
44 | * which handles platform-specific concerns. | ||
45 | */ | ||
46 | export default class RepositoryBasedI18nBackend | ||
47 | implements BackendModule<unknown> | ||
48 | { | ||
49 | type = 'backend' as const; | ||
50 | |||
51 | private writeQueue: Map<string, Map<string, Map<string, string>>> = new Map(); | ||
52 | |||
53 | private keySeparator: string | false = '.'; | ||
54 | |||
55 | private readonly flushWriteQueueImmediately = () => { | ||
56 | const savedWriteQueue = this.writeQueue; | ||
57 | this.writeQueue = new Map(); | ||
58 | savedWriteQueue.forEach((languagesMap, namespace) => { | ||
59 | languagesMap.forEach((keysMap, language) => { | ||
60 | this.flushChanges(language, namespace, keysMap).catch((error) => { | ||
61 | log.error( | ||
62 | 'Cannot write missing', | ||
63 | language, | ||
64 | 'translations for namespace', | ||
65 | namespace, | ||
66 | error, | ||
67 | ); | ||
68 | }); | ||
69 | }); | ||
70 | }); | ||
71 | }; | ||
72 | |||
73 | private readonly flushWriteQueue = debounce( | ||
74 | this.flushWriteQueueImmediately, | ||
75 | MISSING_ENTRIES_DEBOUNCE_TIME, | ||
76 | ); | ||
77 | |||
78 | constructor( | ||
79 | private readonly repository: LocatlizationRepository, | ||
80 | private readonly devMode = false, | ||
81 | ) {} | ||
82 | |||
83 | init( | ||
84 | _services: Services, | ||
85 | _backendOptions: unknown, | ||
86 | { keySeparator }: InitOptions, | ||
87 | ) { | ||
88 | if (keySeparator !== undefined) { | ||
89 | this.keySeparator = keySeparator; | ||
90 | } | ||
91 | } | ||
92 | |||
93 | read(language: string, namespace: string, callback: ReadCallback): void { | ||
94 | const readAsync = async () => { | ||
95 | const translations = await this.repository.getResourceContents( | ||
96 | language, | ||
97 | namespace, | ||
98 | ); | ||
99 | // eslint-disable-next-line unicorn/no-null -- `i18next` API requires `null`. | ||
100 | setImmediate(() => callback(null, translations)); | ||
101 | }; | ||
102 | |||
103 | readAsync().catch((error) => { | ||
104 | log.error( | ||
105 | 'Error while loading', | ||
106 | language, | ||
107 | 'translations for namespace', | ||
108 | namespace, | ||
109 | error, | ||
110 | ); | ||
111 | const callbackError = | ||
112 | error instanceof Error | ||
113 | ? error | ||
114 | : new Error(`Unknown error: ${JSON.stringify(error)}`); | ||
115 | /* | ||
116 | eslint-disable-next-line promise/no-callback-in-promise, unicorn/no-null -- | ||
117 | Converting from promise based API to a callback. `i18next` API requires `null`. | ||
118 | */ | ||
119 | setImmediate(() => callback(callbackError, null)); | ||
120 | }); | ||
121 | } | ||
122 | |||
123 | create( | ||
124 | languages: string[], | ||
125 | namespace: string, | ||
126 | key: string, | ||
127 | fallbackValue: string, | ||
128 | ): void { | ||
129 | if (!this.devMode) { | ||
130 | throw new Error( | ||
131 | 'Refusing to write missing translations in production mode', | ||
132 | ); | ||
133 | } | ||
134 | let languagesMapOrUndefined = this.writeQueue.get(namespace); | ||
135 | if (languagesMapOrUndefined === undefined) { | ||
136 | languagesMapOrUndefined = new Map(); | ||
137 | this.writeQueue.set(namespace, languagesMapOrUndefined); | ||
138 | } | ||
139 | const languagesMap = languagesMapOrUndefined; | ||
140 | languages.forEach((language) => { | ||
141 | let keysMap = languagesMap.get(language); | ||
142 | if (keysMap === undefined) { | ||
143 | keysMap = new Map(); | ||
144 | languagesMap.set(language, keysMap); | ||
145 | } | ||
146 | keysMap.set(key, fallbackValue); | ||
147 | }); | ||
148 | this.flushWriteQueue(); | ||
149 | } | ||
150 | |||
151 | /** | ||
152 | * Flushes new translations to the missing translations file. | ||
153 | * | ||
154 | * Will always fails if `devMode` is `false`. | ||
155 | * If no changes are made to the translations, the file won't be rewritten. | ||
156 | * | ||
157 | * @param language The language to add translations for. | ||
158 | * @param namespace The namespace to add translations for. | ||
159 | * @param entries The translations to add. | ||
160 | * @returns A promise that resolves when changes were flushed successfully. | ||
161 | */ | ||
162 | private async flushChanges( | ||
163 | language: string, | ||
164 | namespace: string, | ||
165 | entries: Map<string, string>, | ||
166 | ): Promise<void> { | ||
167 | if (!this.devMode) { | ||
168 | throw new Error( | ||
169 | 'Refusing to write missing translations in production mode', | ||
170 | ); | ||
171 | } | ||
172 | const resourceContents = await this.repository.getMissingResourceContents( | ||
173 | language, | ||
174 | namespace, | ||
175 | ); | ||
176 | const translations = | ||
177 | typeof resourceContents === 'object' ? resourceContents : {}; | ||
178 | let changed = false; | ||
179 | entries.forEach((value, key) => { | ||
180 | const splitKey = this.keySeparator ? key.split(this.keySeparator) : [key]; | ||
181 | let obj = translations; | ||
182 | /* | ||
183 | eslint-disable @typescript-eslint/no-unsafe-assignment, | ||
184 | security/detect-object-injection -- | ||
185 | We need to modify raw objects, because this is the storage format for i18next. | ||
186 | To mitigate the prototype injection security risk, | ||
187 | we make sure to only ever run this in development mode. | ||
188 | */ | ||
189 | for (let i = 0; i < splitKey.length - 1; i += 1) { | ||
190 | let nextObj = obj[splitKey[i]]; | ||
191 | if (typeof nextObj !== 'object') { | ||
192 | nextObj = {}; | ||
193 | obj[splitKey[i]] = nextObj; | ||
194 | } | ||
195 | obj = nextObj; | ||
196 | } | ||
197 | const lastKey = splitKey[splitKey.length - 1]; | ||
198 | if (obj[lastKey] !== value) { | ||
199 | obj[lastKey] = value; | ||
200 | changed = true; | ||
201 | } | ||
202 | /* | ||
203 | eslint-enable @typescript-eslint/no-unsafe-assignment, | ||
204 | security/detect-object-injection | ||
205 | */ | ||
206 | }); | ||
207 | if (!changed) { | ||
208 | return; | ||
209 | } | ||
210 | await this.repository.setMissingResourceContents( | ||
211 | language, | ||
212 | namespace, | ||
213 | translations, | ||
214 | ); | ||
215 | log.debug( | ||
216 | 'Wrote', | ||
217 | entries.size, | ||
218 | 'missing', | ||
219 | language, | ||
220 | 'translations for namespace', | ||
221 | namespace, | ||
222 | ); | ||
223 | } | ||
224 | } | ||
diff --git a/packages/main/src/i18n/i18nLog.ts b/packages/main/src/i18n/i18nLog.ts new file mode 100644 index 0000000..33a8c4e --- /dev/null +++ b/packages/main/src/i18n/i18nLog.ts | |||
@@ -0,0 +1,41 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import type { LoggerModule } from 'i18next'; | ||
22 | |||
23 | import { getLogger } from '../utils/log'; | ||
24 | |||
25 | const log = getLogger('i18nLog'); | ||
26 | |||
27 | /* | ||
28 | eslint-disable-next-line @typescript-eslint/unbound-method -- | ||
29 | loglevel log methods don't rely on `this`, so this is safe, | ||
30 | and also keeps stacktraces smaller. | ||
31 | */ | ||
32 | const { debug, warn, error } = log; | ||
33 | |||
34 | const i18nLog: LoggerModule = { | ||
35 | type: 'logger', | ||
36 | log: debug, | ||
37 | warn, | ||
38 | error, | ||
39 | }; | ||
40 | |||
41 | export default i18nLog; | ||
diff --git a/packages/main/src/i18n/impl/LocaltizationFiles.ts b/packages/main/src/i18n/impl/LocaltizationFiles.ts new file mode 100644 index 0000000..73a769e --- /dev/null +++ b/packages/main/src/i18n/impl/LocaltizationFiles.ts | |||
@@ -0,0 +1,93 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import { readFile, writeFile } from 'node:fs/promises'; | ||
22 | |||
23 | import type { ResourceKey } from 'i18next'; | ||
24 | |||
25 | import type Resources from '../../infrastructure/resources/Resources'; | ||
26 | import isErrno from '../../utils/isErrno'; | ||
27 | import { getLogger } from '../../utils/log'; | ||
28 | import type LocatlizationRepository from '../LocalizationRepository'; | ||
29 | |||
30 | const log = getLogger('LocalizationFiles'); | ||
31 | |||
32 | export default class LocalizationFiles implements LocatlizationRepository { | ||
33 | constructor(private readonly resources: Resources) {} | ||
34 | |||
35 | async getResourceContents( | ||
36 | language: string, | ||
37 | namespace: string, | ||
38 | ): Promise<ResourceKey> { | ||
39 | const fileName = this.resources.getLocalizationPath( | ||
40 | language, | ||
41 | `${namespace}.json`, | ||
42 | ); | ||
43 | const contents = await readFile(fileName, 'utf8'); | ||
44 | log.info('Read localization file', fileName); | ||
45 | // The contents of the file should come from a signed archive during production, | ||
46 | // so we don't need to (and can't) validate them. | ||
47 | return JSON.parse(contents) as ResourceKey; | ||
48 | } | ||
49 | |||
50 | private getMissingLocalizationPath( | ||
51 | language: string, | ||
52 | namespace: string, | ||
53 | ): string { | ||
54 | return this.resources.getLocalizationPath( | ||
55 | language, | ||
56 | `${namespace}.missing.json`, | ||
57 | ); | ||
58 | } | ||
59 | |||
60 | async getMissingResourceContents( | ||
61 | language: string, | ||
62 | namespace: string, | ||
63 | ): Promise<ResourceKey> { | ||
64 | const fileName = this.getMissingLocalizationPath(language, namespace); | ||
65 | try { | ||
66 | const contents = await readFile(fileName, 'utf8'); | ||
67 | // The contents of the file are only used during development, | ||
68 | // so we don't need to (and can't) validate them. | ||
69 | return JSON.parse(contents) as ResourceKey; | ||
70 | } catch (error) { | ||
71 | if (isErrno(error, 'ENOENT')) { | ||
72 | log.debug( | ||
73 | 'No missing translations file', | ||
74 | language, | ||
75 | 'for namespace', | ||
76 | namespace, | ||
77 | ); | ||
78 | return {}; | ||
79 | } | ||
80 | throw error; | ||
81 | } | ||
82 | } | ||
83 | |||
84 | setMissingResourceContents( | ||
85 | language: string, | ||
86 | namespace: string, | ||
87 | data: ResourceKey, | ||
88 | ): Promise<void> { | ||
89 | const fileName = this.getMissingLocalizationPath(language, namespace); | ||
90 | const contents = JSON.stringify(data, undefined, 2); | ||
91 | return writeFile(fileName, contents, 'utf8'); | ||
92 | } | ||
93 | } | ||
diff --git a/packages/main/src/i18n/loadLocalization.ts b/packages/main/src/i18n/loadLocalization.ts new file mode 100644 index 0000000..1408a30 --- /dev/null +++ b/packages/main/src/i18n/loadLocalization.ts | |||
@@ -0,0 +1,50 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import i18next from 'i18next'; | ||
22 | |||
23 | import type Resources from '../infrastructure/resources/Resources'; | ||
24 | import type MainStore from '../stores/MainStore'; | ||
25 | |||
26 | import I18nStore from './I18nStore'; | ||
27 | import RepositoryBasedI18nBackend from './RepositoryBasedI18nBackend'; | ||
28 | import i18nLog from './i18nLog'; | ||
29 | import LocalizationFiles from './impl/LocaltizationFiles'; | ||
30 | |||
31 | export default async function loadLocalization( | ||
32 | store: MainStore, | ||
33 | resources: Resources, | ||
34 | devMode: boolean, | ||
35 | ): Promise<void> { | ||
36 | const repository = new LocalizationFiles(resources); | ||
37 | const backend = new RepositoryBasedI18nBackend(repository, devMode); | ||
38 | const i18n = i18next | ||
39 | .createInstance({ | ||
40 | lng: 'en', | ||
41 | fallbackLng: ['en'], | ||
42 | debug: devMode, | ||
43 | saveMissing: devMode, | ||
44 | }) | ||
45 | .use(backend) | ||
46 | .use(i18nLog); | ||
47 | await i18n.init(); | ||
48 | const i18nStore = new I18nStore(i18n); | ||
49 | store.setI18n(i18nStore); | ||
50 | } | ||
diff --git a/packages/main/src/infrastructure/config/impl/ConfigFile.ts b/packages/main/src/infrastructure/config/impl/ConfigFile.ts index e8237b4..4ad0fcc 100644 --- a/packages/main/src/infrastructure/config/impl/ConfigFile.ts +++ b/packages/main/src/infrastructure/config/impl/ConfigFile.ts | |||
@@ -27,6 +27,7 @@ import { throttle } from 'lodash-es'; | |||
27 | 27 | ||
28 | import type Config from '../../../stores/config/Config'; | 28 | import type Config from '../../../stores/config/Config'; |
29 | import type Disposer from '../../../utils/Disposer'; | 29 | import type Disposer from '../../../utils/Disposer'; |
30 | import isErrno from '../../../utils/isErrno'; | ||
30 | import { getLogger } from '../../../utils/log'; | 31 | import { getLogger } from '../../../utils/log'; |
31 | import type ConfigRepository from '../ConfigRepository'; | 32 | import type ConfigRepository from '../ConfigRepository'; |
32 | import type { ReadConfigResult } from '../ConfigRepository'; | 33 | import type { ReadConfigResult } from '../ConfigRepository'; |
@@ -55,7 +56,7 @@ export default class ConfigFile implements ConfigRepository { | |||
55 | try { | 56 | try { |
56 | configStr = await readFile(this.#configFilePath, 'utf8'); | 57 | configStr = await readFile(this.#configFilePath, 'utf8'); |
57 | } catch (error) { | 58 | } catch (error) { |
58 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { | 59 | if (isErrno(error, 'ENOENT')) { |
59 | log.debug('Config file', this.#configFilePath, 'was not found'); | 60 | log.debug('Config file', this.#configFilePath, 'was not found'); |
60 | return { found: false }; | 61 | return { found: false }; |
61 | } | 62 | } |
@@ -94,7 +95,7 @@ export default class ConfigFile implements ConfigRepository { | |||
94 | mtime = stats.mtime; | 95 | mtime = stats.mtime; |
95 | log.trace('Config file last modified at', mtime); | 96 | log.trace('Config file last modified at', mtime); |
96 | } catch (error) { | 97 | } catch (error) { |
97 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { | 98 | if (isErrno(error, 'ENOENT')) { |
98 | log.debug( | 99 | log.debug( |
99 | 'Config file', | 100 | 'Config file', |
100 | this.#configFilePath, | 101 | this.#configFilePath, |
diff --git a/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts b/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts index 5166719..49bfbfd 100644 --- a/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts +++ b/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts | |||
@@ -30,6 +30,12 @@ export default function setApplicationMenu( | |||
30 | isMac: boolean, | 30 | isMac: boolean, |
31 | ): void { | 31 | ): void { |
32 | const dispose = autorun(() => { | 32 | const dispose = autorun(() => { |
33 | const translation = store.useTranslation(); | ||
34 | if (!translation.ready) { | ||
35 | return; | ||
36 | } | ||
37 | const { t } = translation; | ||
38 | |||
33 | const { settings, shared, visibleService } = store; | 39 | const { settings, shared, visibleService } = store; |
34 | const { showLocationBar, selectedService } = settings; | 40 | const { showLocationBar, selectedService } = settings; |
35 | const { canSwitchServices, services } = shared; | 41 | const { canSwitchServices, services } = shared; |
@@ -42,7 +48,7 @@ export default function setApplicationMenu( | |||
42 | role: 'viewMenu', | 48 | role: 'viewMenu', |
43 | submenu: [ | 49 | submenu: [ |
44 | { | 50 | { |
45 | label: 'Show Location Bar', | 51 | label: t<string>('menu.view.showLocationBar'), |
46 | accelerator: 'CommandOrControl+Shift+L', | 52 | accelerator: 'CommandOrControl+Shift+L', |
47 | type: 'checkbox', | 53 | type: 'checkbox', |
48 | checked: showLocationBar, | 54 | checked: showLocationBar, |
@@ -52,7 +58,7 @@ export default function setApplicationMenu( | |||
52 | }, | 58 | }, |
53 | { type: 'separator' }, | 59 | { type: 'separator' }, |
54 | { | 60 | { |
55 | label: 'Reload', | 61 | label: t<string>('menu.view.reload'), |
56 | accelerator: 'CommandOrControl+R', | 62 | accelerator: 'CommandOrControl+R', |
57 | enabled: selectedService !== undefined, | 63 | enabled: selectedService !== undefined, |
58 | click() { | 64 | click() { |
@@ -60,7 +66,7 @@ export default function setApplicationMenu( | |||
60 | }, | 66 | }, |
61 | }, | 67 | }, |
62 | { | 68 | { |
63 | label: 'Force Reload', | 69 | label: t<string>('menu.view.forceReload'), |
64 | accelerator: 'CommandOrControl+Shift+R', | 70 | accelerator: 'CommandOrControl+Shift+R', |
65 | enabled: selectedService !== undefined, | 71 | enabled: selectedService !== undefined, |
66 | click() { | 72 | click() { |
@@ -68,7 +74,7 @@ export default function setApplicationMenu( | |||
68 | }, | 74 | }, |
69 | }, | 75 | }, |
70 | { | 76 | { |
71 | label: 'Toggle Developer Tools', | 77 | label: t<string>('menu.view.toggleDeveloperTools'), |
72 | accelerator: 'CommandOrControl+Shift+I', | 78 | accelerator: 'CommandOrControl+Shift+I', |
73 | enabled: visibleService !== undefined, | 79 | enabled: visibleService !== undefined, |
74 | click() { | 80 | click() { |
@@ -80,12 +86,15 @@ export default function setApplicationMenu( | |||
80 | ? ([ | 86 | ? ([ |
81 | { | 87 | { |
82 | role: 'forceReload', | 88 | role: 'forceReload', |
83 | label: 'Reload Sophie', | 89 | label: t<string>('menu.view.reloadSophie'), |
84 | accelerator: 'CommandOrControl+Shift+Alt+R', | 90 | accelerator: 'CommandOrControl+Shift+Alt+R', |
85 | }, | 91 | }, |
86 | { | 92 | { |
87 | role: 'toggleDevTools', | 93 | role: 'toggleDevTools', |
88 | label: 'Toggle Sophie Developer Tools', | 94 | label: t<string>( |
95 | 'menu.view.toggleSophieDeveloperTools', | ||
96 | 'Toggle Sophie Developer Tools', | ||
97 | ), | ||
89 | accelerator: 'CommandOrControl+Shift+Alt+I', | 98 | accelerator: 'CommandOrControl+Shift+Alt+I', |
90 | }, | 99 | }, |
91 | { type: 'separator' }, | 100 | { type: 'separator' }, |
@@ -95,10 +104,10 @@ export default function setApplicationMenu( | |||
95 | ], | 104 | ], |
96 | }, | 105 | }, |
97 | { | 106 | { |
98 | label: 'Services', | 107 | label: t<string>('menu.servicesMenu'), |
99 | submenu: [ | 108 | submenu: [ |
100 | { | 109 | { |
101 | label: 'Next Service', | 110 | label: t<string>('menu.services.nextService'), |
102 | accelerator: 'CommandOrControl+Tab', | 111 | accelerator: 'CommandOrControl+Tab', |
103 | enabled: canSwitchServices, | 112 | enabled: canSwitchServices, |
104 | click() { | 113 | click() { |
@@ -106,7 +115,7 @@ export default function setApplicationMenu( | |||
106 | }, | 115 | }, |
107 | }, | 116 | }, |
108 | { | 117 | { |
109 | label: 'Previous Service', | 118 | label: t<string>('menu.services.previousService'), |
110 | accelerator: 'CommandOrControl+Shift+Tab', | 119 | accelerator: 'CommandOrControl+Shift+Tab', |
111 | enabled: canSwitchServices, | 120 | enabled: canSwitchServices, |
112 | click() { | 121 | click() { |
@@ -140,7 +149,7 @@ export default function setApplicationMenu( | |||
140 | role: 'help', | 149 | role: 'help', |
141 | submenu: [ | 150 | submenu: [ |
142 | { | 151 | { |
143 | label: 'Gitlab', | 152 | label: t<string>('menu.help.gitlab'), |
144 | click() { | 153 | click() { |
145 | store.openWebpageInBrowser(); | 154 | store.openWebpageInBrowser(); |
146 | }, | 155 | }, |
diff --git a/packages/main/src/infrastructure/resources/Resources.ts b/packages/main/src/infrastructure/resources/Resources.ts index 269c838..0e90c6b 100644 --- a/packages/main/src/infrastructure/resources/Resources.ts +++ b/packages/main/src/infrastructure/resources/Resources.ts | |||
@@ -21,6 +21,8 @@ | |||
21 | export default interface Resources { | 21 | export default interface Resources { |
22 | getPath(packageName: string, relativePathInPackage: string): string; | 22 | getPath(packageName: string, relativePathInPackage: string): string; |
23 | 23 | ||
24 | getLocalizationPath(language: string, fileName: string): string; | ||
25 | |||
24 | getFileURL(packageName: string, relativePathInPackage: string): string; | 26 | getFileURL(packageName: string, relativePathInPackage: string): string; |
25 | 27 | ||
26 | getRendererURL(relativePathInRendererPackage: string): string; | 28 | getRendererURL(relativePathInRendererPackage: string): string; |
diff --git a/packages/main/src/infrastructure/resources/impl/getDistResources.ts b/packages/main/src/infrastructure/resources/impl/getDistResources.ts index f3c3f7b..3c1ffb3 100644 --- a/packages/main/src/infrastructure/resources/impl/getDistResources.ts +++ b/packages/main/src/infrastructure/resources/impl/getDistResources.ts | |||
@@ -33,6 +33,7 @@ export default function getDistResources( | |||
33 | devServerURL = import.meta.env?.VITE_DEV_SERVER_URL, | 33 | devServerURL = import.meta.env?.VITE_DEV_SERVER_URL, |
34 | ): Resources { | 34 | ): Resources { |
35 | const packagesRoot = path.join(thisDir, '..', '..'); | 35 | const packagesRoot = path.join(thisDir, '..', '..'); |
36 | const localizationRoot = path.join(packagesRoot, '..', 'locales'); | ||
36 | 37 | ||
37 | function getPath(packageName: string, relativePathInPackage: string): string { | 38 | function getPath(packageName: string, relativePathInPackage: string): string { |
38 | return path.join(packagesRoot, packageName, 'dist', relativePathInPackage); | 39 | return path.join(packagesRoot, packageName, 'dist', relativePathInPackage); |
@@ -48,6 +49,9 @@ export default function getDistResources( | |||
48 | 49 | ||
49 | return { | 50 | return { |
50 | getPath, | 51 | getPath, |
52 | getLocalizationPath(language, fileName) { | ||
53 | return path.join(localizationRoot, language, fileName); | ||
54 | }, | ||
51 | getFileURL, | 55 | getFileURL, |
52 | getRendererURL: | 56 | getRendererURL: |
53 | devMode && devServerURL !== undefined | 57 | devMode && devServerURL !== undefined |
diff --git a/packages/main/src/initReactions.ts b/packages/main/src/initReactions.ts index 9c49fc5..cdff551 100644 --- a/packages/main/src/initReactions.ts +++ b/packages/main/src/initReactions.ts | |||
@@ -20,6 +20,7 @@ | |||
20 | 20 | ||
21 | import { app, session } from 'electron'; | 21 | import { app, session } from 'electron'; |
22 | 22 | ||
23 | import loadLocalization from './i18n/loadLocalization'; | ||
23 | import ConfigFile from './infrastructure/config/impl/ConfigFile'; | 24 | import ConfigFile from './infrastructure/config/impl/ConfigFile'; |
24 | import UserAgents from './infrastructure/electron/UserAgents'; | 25 | import UserAgents from './infrastructure/electron/UserAgents'; |
25 | import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory'; | 26 | import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory'; |
@@ -52,7 +53,11 @@ export default async function initReactions( | |||
52 | } | 53 | } |
53 | const userAgents = new UserAgents(app.userAgentFallback); | 54 | const userAgents = new UserAgents(app.userAgentFallback); |
54 | app.userAgentFallback = userAgents.fallbackUserAgent(devMode); | 55 | app.userAgentFallback = userAgents.fallbackUserAgent(devMode); |
55 | setApplicationMenu(store, devMode, isMac); | 56 | const localizeInterface = async () => { |
57 | await loadLocalization(store, resources, devMode); | ||
58 | setApplicationMenu(store, devMode, isMac); | ||
59 | }; | ||
60 | const localization = localizeInterface(); | ||
56 | const viewFactory = new ElectronViewFactory(userAgents, resources, devMode); | 61 | const viewFactory = new ElectronViewFactory(userAgents, resources, devMode); |
57 | const [mainWindow] = await Promise.all([ | 62 | const [mainWindow] = await Promise.all([ |
58 | viewFactory.createMainWindow(store), | 63 | viewFactory.createMainWindow(store), |
@@ -60,6 +65,7 @@ export default async function initReactions( | |||
60 | ]); | 65 | ]); |
61 | store.setMainWindow(mainWindow); | 66 | store.setMainWindow(mainWindow); |
62 | loadServices(store, viewFactory); | 67 | loadServices(store, viewFactory); |
68 | await localization; | ||
63 | return () => { | 69 | return () => { |
64 | disposeNativeThemeController(); | 70 | disposeNativeThemeController(); |
65 | disposeConfigController(); | 71 | disposeConfigController(); |
diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts index 86fadcb..873f998 100644 --- a/packages/main/src/stores/MainStore.ts +++ b/packages/main/src/stores/MainStore.ts | |||
@@ -21,6 +21,8 @@ | |||
21 | import type { Action, BrowserViewBounds } from '@sophie/shared'; | 21 | import type { Action, BrowserViewBounds } from '@sophie/shared'; |
22 | import { applySnapshot, Instance, types } from 'mobx-state-tree'; | 22 | import { applySnapshot, Instance, types } from 'mobx-state-tree'; |
23 | 23 | ||
24 | import type I18nStore from '../i18n/I18nStore'; | ||
25 | import type { UseTranslationResult } from '../i18n/I18nStore'; | ||
24 | import type { MainWindow } from '../infrastructure/electron/types'; | 26 | import type { MainWindow } from '../infrastructure/electron/types'; |
25 | import { getLogger } from '../utils/log'; | 27 | import { getLogger } from '../utils/log'; |
26 | 28 | ||
@@ -44,6 +46,7 @@ const MainStore = types | |||
44 | {}, | 46 | {}, |
45 | ), | 47 | ), |
46 | shared: types.optional(SharedStore, {}), | 48 | shared: types.optional(SharedStore, {}), |
49 | i18n: types.frozen<I18nStore | undefined>(), | ||
47 | }) | 50 | }) |
48 | .views((self) => ({ | 51 | .views((self) => ({ |
49 | get settings(): GlobalSettings { | 52 | get settings(): GlobalSettings { |
@@ -61,6 +64,9 @@ const MainStore = types | |||
61 | ? selectedService | 64 | ? selectedService |
62 | : undefined; | 65 | : undefined; |
63 | }, | 66 | }, |
67 | useTranslation(ns?: string): UseTranslationResult { | ||
68 | return self.i18n?.useTranslation(ns) ?? { ready: false }; | ||
69 | }, | ||
64 | })) | 70 | })) |
65 | .volatile( | 71 | .volatile( |
66 | (): { | 72 | (): { |
@@ -124,6 +130,9 @@ const MainStore = types | |||
124 | beforeDestroy(): void { | 130 | beforeDestroy(): void { |
125 | self.mainWindow?.dispose(); | 131 | self.mainWindow?.dispose(); |
126 | }, | 132 | }, |
133 | setI18n(i18n: I18nStore): void { | ||
134 | self.i18n = i18n; | ||
135 | }, | ||
127 | })); | 136 | })); |
128 | 137 | ||
129 | /* | 138 | /* |
diff --git a/packages/main/src/utils/isErrno.ts b/packages/main/src/utils/isErrno.ts new file mode 100644 index 0000000..8adbc33 --- /dev/null +++ b/packages/main/src/utils/isErrno.ts | |||
@@ -0,0 +1,28 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2021-2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | export default function isErrno(error: unknown, code: string): boolean { | ||
22 | return ( | ||
23 | typeof error === 'object' && | ||
24 | error !== null && | ||
25 | 'code' in error && | ||
26 | (error as NodeJS.ErrnoException).code === code | ||
27 | ); | ||
28 | } | ||
@@ -448,6 +448,15 @@ __metadata: | |||
448 | languageName: node | 448 | languageName: node |
449 | linkType: hard | 449 | linkType: hard |
450 | 450 | ||
451 | "@babel/runtime@npm:^7.17.2": | ||
452 | version: 7.17.8 | ||
453 | resolution: "@babel/runtime@npm:7.17.8" | ||
454 | dependencies: | ||
455 | regenerator-runtime: ^0.13.4 | ||
456 | checksum: 68d195c1630bb91ac20e86635d292a17ebab7f361cfe79406b3f5a6cc2e59fa283ae5006568899abf869312c2b35b744bd407aea8ffdb650f1a68d07785d47e9 | ||
457 | languageName: node | ||
458 | linkType: hard | ||
459 | |||
451 | "@babel/template@npm:^7.16.7, @babel/template@npm:^7.3.3": | 460 | "@babel/template@npm:^7.16.7, @babel/template@npm:^7.3.3": |
452 | version: 7.16.7 | 461 | version: 7.16.7 |
453 | resolution: "@babel/template@npm:7.16.7" | 462 | resolution: "@babel/template@npm:7.16.7" |
@@ -1286,6 +1295,7 @@ __metadata: | |||
1286 | esbuild: ^0.14.23 | 1295 | esbuild: ^0.14.23 |
1287 | fs-extra: ^10.0.1 | 1296 | fs-extra: ^10.0.1 |
1288 | git-repo-info: ^2.1.1 | 1297 | git-repo-info: ^2.1.1 |
1298 | i18next: ^21.6.14 | ||
1289 | jest: ^27.5.1 | 1299 | jest: ^27.5.1 |
1290 | jest-mock: ^27.5.1 | 1300 | jest-mock: ^27.5.1 |
1291 | json5: ^2.2.0 | 1301 | json5: ^2.2.0 |
@@ -3231,13 +3241,6 @@ __metadata: | |||
3231 | languageName: node | 3241 | languageName: node |
3232 | linkType: hard | 3242 | linkType: hard |
3233 | 3243 | ||
3234 | "deep-equal@npm:~0.0.0": | ||
3235 | version: 0.0.0 | ||
3236 | resolution: "deep-equal@npm:0.0.0" | ||
3237 | checksum: 024cf06cfe917e2f2debf531891537fa21a859a8e5696085d97aefc89e20c66ac20f98dd472fcbe0001f235a3b978a1f5bbbfd661ca795d87e5832b3831bd01a | ||
3238 | languageName: node | ||
3239 | linkType: hard | ||
3240 | |||
3241 | "deep-extend@npm:^0.6.0": | 3244 | "deep-extend@npm:^0.6.0": |
3242 | version: 0.6.0 | 3245 | version: 0.6.0 |
3243 | resolution: "deep-extend@npm:0.6.0" | 3246 | resolution: "deep-extend@npm:0.6.0" |
@@ -3275,13 +3278,6 @@ __metadata: | |||
3275 | languageName: node | 3278 | languageName: node |
3276 | linkType: hard | 3279 | linkType: hard |
3277 | 3280 | ||
3278 | "defined@npm:~0.0.0": | ||
3279 | version: 0.0.0 | ||
3280 | resolution: "defined@npm:0.0.0" | ||
3281 | checksum: 037f80bb4d8b92e72ca99f1b24d1d9d683741b28b9cb9c7b5f4a4c913826fd7f4b103440a623a3882f2218ec3d87649fbf86c65c45e6999cf1124b9a8924fca5 | ||
3282 | languageName: node | ||
3283 | linkType: hard | ||
3284 | |||
3285 | "delayed-stream@npm:~1.0.0": | 3281 | "delayed-stream@npm:~1.0.0": |
3286 | version: 1.0.0 | 3282 | version: 1.0.0 |
3287 | resolution: "delayed-stream@npm:1.0.0" | 3283 | resolution: "delayed-stream@npm:1.0.0" |
@@ -4820,16 +4816,6 @@ __metadata: | |||
4820 | languageName: node | 4816 | languageName: node |
4821 | linkType: hard | 4817 | linkType: hard |
4822 | 4818 | ||
4823 | "global@npm:~2.0.7": | ||
4824 | version: 2.0.7 | ||
4825 | resolution: "global@npm:2.0.7" | ||
4826 | dependencies: | ||
4827 | min-document: ~0.2.2 | ||
4828 | process: ~0.5.1 | ||
4829 | checksum: f2ae2bfd251c014cd8273680b4be221f4c20eb07ad9bfce21f6de7bddbcb5d58b393488e8ebf119ede54addb561c648aa47b2407a630c3a69188c8d11c5b0d75 | ||
4830 | languageName: node | ||
4831 | linkType: hard | ||
4832 | |||
4833 | "globals@npm:^11.1.0": | 4819 | "globals@npm:^11.1.0": |
4834 | version: 11.12.0 | 4820 | version: 11.12.0 |
4835 | resolution: "globals@npm:11.12.0" | 4821 | resolution: "globals@npm:11.12.0" |
@@ -5067,6 +5053,15 @@ __metadata: | |||
5067 | languageName: node | 5053 | languageName: node |
5068 | linkType: hard | 5054 | linkType: hard |
5069 | 5055 | ||
5056 | "i18next@npm:^21.6.14": | ||
5057 | version: 21.6.14 | ||
5058 | resolution: "i18next@npm:21.6.14" | ||
5059 | dependencies: | ||
5060 | "@babel/runtime": ^7.17.2 | ||
5061 | checksum: bc6e117874d9b69a39d6ad322851d25f75908c7fa977c8771b98ba7b0273aceba96e82326ed0855b8db098b1490c5a0decbe62b4f61dac84fdc677c2fdc52bb8 | ||
5062 | languageName: node | ||
5063 | linkType: hard | ||
5064 | |||
5070 | "iconv-corefoundation@npm:^1.1.7": | 5065 | "iconv-corefoundation@npm:^1.1.7": |
5071 | version: 1.1.7 | 5066 | version: 1.1.7 |
5072 | resolution: "iconv-corefoundation@npm:1.1.7" | 5067 | resolution: "iconv-corefoundation@npm:1.1.7" |
@@ -5183,7 +5178,7 @@ __metadata: | |||
5183 | languageName: node | 5178 | languageName: node |
5184 | linkType: hard | 5179 | linkType: hard |
5185 | 5180 | ||
5186 | "inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:~2.0.1, inherits@npm:~2.0.3": | 5181 | "inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:~2.0.3": |
5187 | version: 2.0.4 | 5182 | version: 2.0.4 |
5188 | resolution: "inherits@npm:2.0.4" | 5183 | resolution: "inherits@npm:2.0.4" |
5189 | checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 | 5184 | checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 |
@@ -5204,13 +5199,6 @@ __metadata: | |||
5204 | languageName: node | 5199 | languageName: node |
5205 | linkType: hard | 5200 | linkType: hard |
5206 | 5201 | ||
5207 | "insert-css@npm:0.0.0": | ||
5208 | version: 0.0.0 | ||
5209 | resolution: "insert-css@npm:0.0.0" | ||
5210 | checksum: d0441ad656674fe971a594dcf6f53f53cdb8d469668344a7dd15dfa7f2805829d553f3cf799d967256cead4d960f884ffcd27e45df9732916a3c8b7763a16f7b | ||
5211 | languageName: node | ||
5212 | linkType: hard | ||
5213 | |||
5214 | "internal-slot@npm:^1.0.3": | 5202 | "internal-slot@npm:^1.0.3": |
5215 | version: 1.0.3 | 5203 | version: 1.0.3 |
5216 | resolution: "internal-slot@npm:1.0.3" | 5204 | resolution: "internal-slot@npm:1.0.3" |
@@ -6356,13 +6344,6 @@ __metadata: | |||
6356 | languageName: node | 6344 | languageName: node |
6357 | linkType: hard | 6345 | linkType: hard |
6358 | 6346 | ||
6359 | "jsonify@npm:~0.0.0": | ||
6360 | version: 0.0.0 | ||
6361 | resolution: "jsonify@npm:0.0.0" | ||
6362 | checksum: d8d4ed476c116e6987a460dcb82f22284686caae9f498ac87b0502c1765ac1522f4f450a4cad4cc368d202fd3b27a3860735140a82867fc6d558f5f199c38bce | ||
6363 | languageName: node | ||
6364 | linkType: hard | ||
6365 | |||
6366 | "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.2.1": | 6347 | "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.2.1": |
6367 | version: 3.2.1 | 6348 | version: 3.2.1 |
6368 | resolution: "jsx-ast-utils@npm:3.2.1" | 6349 | resolution: "jsx-ast-utils@npm:3.2.1" |
@@ -6692,15 +6673,6 @@ __metadata: | |||
6692 | languageName: node | 6673 | languageName: node |
6693 | linkType: hard | 6674 | linkType: hard |
6694 | 6675 | ||
6695 | "min-document@npm:~0.2.2": | ||
6696 | version: 0.2.8 | ||
6697 | resolution: "min-document@npm:0.2.8" | ||
6698 | dependencies: | ||
6699 | tape: ~1.0.2 | ||
6700 | checksum: 6f861c6169d5b89c8592dafb1247e962319491099b676276e07663be7d4e30d17fc7bcd3576e18dd4e08c5ea6a3a87db33317d813303bdecba0b562a3fb70adc | ||
6701 | languageName: node | ||
6702 | linkType: hard | ||
6703 | |||
6704 | "min-indent@npm:^1.0.0": | 6676 | "min-indent@npm:^1.0.0": |
6705 | version: 1.0.1 | 6677 | version: 1.0.1 |
6706 | resolution: "min-indent@npm:1.0.1" | 6678 | resolution: "min-indent@npm:1.0.1" |
@@ -7122,20 +7094,6 @@ __metadata: | |||
7122 | languageName: node | 7094 | languageName: node |
7123 | linkType: hard | 7095 | linkType: hard |
7124 | 7096 | ||
7125 | "once@npm:~1.1.1": | ||
7126 | version: 1.1.1 | ||
7127 | resolution: "once@npm:1.1.1" | ||
7128 | checksum: c5384f8fc8b973476afe84433ed7202cc5fdbf749abc06908716d706caca1ab951209de38f008ce8f5abca8896edec8aff83c97438a7b6808b5582e01b12864f | ||
7129 | languageName: node | ||
7130 | linkType: hard | ||
7131 | |||
7132 | "once@npm:~1.2.0": | ||
7133 | version: 1.2.0 | ||
7134 | resolution: "once@npm:1.2.0" | ||
7135 | checksum: 4e0b4908bd34dd1db7c83a262e66bcfd077ac4eff94d0499b52878346d1371770147d4d9ae6cc47f0f3a3370570b6b79289daee9ea4cfad143d19caf04849259 | ||
7136 | languageName: node | ||
7137 | linkType: hard | ||
7138 | |||
7139 | "onetime@npm:^5.1.2": | 7097 | "onetime@npm:^5.1.2": |
7140 | version: 5.1.2 | 7098 | version: 5.1.2 |
7141 | resolution: "onetime@npm:5.1.2" | 7099 | resolution: "onetime@npm:5.1.2" |
@@ -7410,18 +7368,6 @@ __metadata: | |||
7410 | languageName: node | 7368 | languageName: node |
7411 | linkType: hard | 7369 | linkType: hard |
7412 | 7370 | ||
7413 | "preload@npm:^0.1.0": | ||
7414 | version: 0.1.0 | ||
7415 | resolution: "preload@npm:0.1.0" | ||
7416 | dependencies: | ||
7417 | inherits: ~2.0.1 | ||
7418 | insert-css: 0.0.0 | ||
7419 | once: ~1.2.0 | ||
7420 | xhr: ~1.2.3 | ||
7421 | checksum: 992aa3b9f6c3b4bd40c87bfbdac389337b046d877258d6fc7e4cfc674b60d72eb3fe83ead47665bc76787c6c2ed1a41ff1dedb073f890f098607df1877e5b639 | ||
7422 | languageName: node | ||
7423 | linkType: hard | ||
7424 | |||
7425 | "prelude-ls@npm:^1.2.1": | 7371 | "prelude-ls@npm:^1.2.1": |
7426 | version: 1.2.1 | 7372 | version: 1.2.1 |
7427 | resolution: "prelude-ls@npm:1.2.1" | 7373 | resolution: "prelude-ls@npm:1.2.1" |
@@ -7479,13 +7425,6 @@ __metadata: | |||
7479 | languageName: node | 7425 | languageName: node |
7480 | linkType: hard | 7426 | linkType: hard |
7481 | 7427 | ||
7482 | "process@npm:~0.5.1": | ||
7483 | version: 0.5.2 | ||
7484 | resolution: "process@npm:0.5.2" | ||
7485 | checksum: 613505ec6d518654fc9c677881c6b8c871179dbf4bfe23f5a7a15defdb58efb1b75bfc96576adc118ba0bca1b76c359687a13f7d43950af0b7d5caa27efbe9e3 | ||
7486 | languageName: node | ||
7487 | linkType: hard | ||
7488 | |||
7489 | "progress@npm:^2.0.3": | 7428 | "progress@npm:^2.0.3": |
7490 | version: 2.0.3 | 7429 | version: 2.0.3 |
7491 | resolution: "progress@npm:2.0.3" | 7430 | resolution: "progress@npm:2.0.3" |
@@ -8299,7 +8238,6 @@ __metadata: | |||
8299 | jest: ^27.5.1 | 8238 | jest: ^27.5.1 |
8300 | jest-junit: ^13.0.0 | 8239 | jest-junit: ^13.0.0 |
8301 | nano-staged: ^0.6.0 | 8240 | nano-staged: ^0.6.0 |
8302 | preload: ^0.1.0 | ||
8303 | prettier: ^2.5.1 | 8241 | prettier: ^2.5.1 |
8304 | rimraf: ^3.0.2 | 8242 | rimraf: ^3.0.2 |
8305 | simple-git-hooks: ^2.7.0 | 8243 | simple-git-hooks: ^2.7.0 |
@@ -8614,20 +8552,6 @@ __metadata: | |||
8614 | languageName: node | 8552 | languageName: node |
8615 | linkType: hard | 8553 | linkType: hard |
8616 | 8554 | ||
8617 | "tape@npm:~1.0.2": | ||
8618 | version: 1.0.4 | ||
8619 | resolution: "tape@npm:1.0.4" | ||
8620 | dependencies: | ||
8621 | deep-equal: ~0.0.0 | ||
8622 | defined: ~0.0.0 | ||
8623 | jsonify: ~0.0.0 | ||
8624 | through: ~2.3.4 | ||
8625 | bin: | ||
8626 | tape: ./bin/tape | ||
8627 | checksum: c8a3e7b0dbb8450d3b18bf8d82a5ac393d0d25ed26a3fa5c1f3205e263f8828b744c6a61cd7d35fa93c4948d2da88232994bf41451186cd9caf77ed87e47871d | ||
8628 | languageName: node | ||
8629 | linkType: hard | ||
8630 | |||
8631 | "tar@npm:^6.0.2, tar@npm:^6.1.2": | 8555 | "tar@npm:^6.0.2, tar@npm:^6.1.2": |
8632 | version: 6.1.11 | 8556 | version: 6.1.11 |
8633 | resolution: "tar@npm:6.1.11" | 8557 | resolution: "tar@npm:6.1.11" |
@@ -8687,13 +8611,6 @@ __metadata: | |||
8687 | languageName: node | 8611 | languageName: node |
8688 | linkType: hard | 8612 | linkType: hard |
8689 | 8613 | ||
8690 | "through@npm:~2.3.4": | ||
8691 | version: 2.3.8 | ||
8692 | resolution: "through@npm:2.3.8" | ||
8693 | checksum: a38c3e059853c494af95d50c072b83f8b676a9ba2818dcc5b108ef252230735c54e0185437618596c790bbba8fcdaef5b290405981ffa09dce67b1f1bf190cbd | ||
8694 | languageName: node | ||
8695 | linkType: hard | ||
8696 | |||
8697 | "tmp-promise@npm:^3.0.2": | 8614 | "tmp-promise@npm:^3.0.2": |
8698 | version: 3.0.3 | 8615 | version: 3.0.3 |
8699 | resolution: "tmp-promise@npm:3.0.3" | 8616 | resolution: "tmp-promise@npm:3.0.3" |
@@ -9401,16 +9318,6 @@ __metadata: | |||
9401 | languageName: node | 9318 | languageName: node |
9402 | linkType: hard | 9319 | linkType: hard |
9403 | 9320 | ||
9404 | "xhr@npm:~1.2.3": | ||
9405 | version: 1.2.4 | ||
9406 | resolution: "xhr@npm:1.2.4" | ||
9407 | dependencies: | ||
9408 | global: ~2.0.7 | ||
9409 | once: ~1.1.1 | ||
9410 | checksum: 55840633fc96bfee99a9c7c839aa8db670f061fc13c45a4056f66c1194b0525f511dbcb5f01ba6ea2f02908f600fd19af13e6d24622ba26b168afbfd94f9211a | ||
9411 | languageName: node | ||
9412 | linkType: hard | ||
9413 | |||
9414 | "xml-name-validator@npm:^3.0.0": | 9321 | "xml-name-validator@npm:^3.0.0": |
9415 | version: 3.0.0 | 9322 | version: 3.0.0 |
9416 | resolution: "xml-name-validator@npm:3.0.0" | 9323 | resolution: "xml-name-validator@npm:3.0.0" |