@samitouri / QOSami-HFS / commits / 98f8eb0c

plugins: api.i18n

Massimo Melina committed Dec 9, 2024 at 20:44 UTC 98f8eb0c57563fc90f9df8c602c5b512b24bb850
22 files changed +185 -147
dev-plugins.md
+14 -5
@@ -228,12 +228,15 @@ The `api` object you get as parameter of the `init` contains the following:
228 - `customApiCall(method: string, ...params): any[]` this will invoke other plugins if they define `method`
229 exported inside `customApi: object`
230
231 -- `openDb(filename, options): Promise<{ get, put, del, close, unlink, sublevel }>` LevelDB-like class for storage.
231 +- `openDb(filename: string, options): Promise<{ get, put, del, close, unlink, sublevel }>` LevelDB-like class for storage.
232 The specified file name will be stored in the "storage" folder of the plugin, by default.
233 DB is automatically closed when the plugin is unloaded. Refer to [dedicated documentation](https://www.npmjs.com/package/@rejetto/kvstorage) for details.
234
235 - `notifyClient(channel: string, eventName: string, data?: any)` send a message to those frontends that are on the same channel.
236
237 +- `i18n(ctx: Context): Promise<{ t }>` if you need to translate messages inside http body, without the GUI, use this function
238 + to instantiate translation for the language of the browser. You can then use the `t` function as documented in [dedicated section](Internationalization-i18n).
239 +
240 - `misc` many functions and constants available in [misc.ts](https://github.com/rejetto/hfs/blob/main/src/misc.ts).
241 These are not documented, probably never will, and are subject to change without notifications,
242 but you can study the sources if you are interested in using them. It's just a shorter version of `api.require('./misc')`
@@ -655,14 +658,19 @@ Where `h` is just `import { createElement as h } from 'react'`.
658
659 ## Internationalization (i18n)
660
658 -To make your plugin multi-language you can use `HFS.t` function in javascript, like this: `HFS.t('myPlugin_greeting', "Hello!")`.
659 -Now, to add translations, you'll add files like `hfs-lang-XX.json` to your plugin (same folder as plugin.js),
661 +To make your plugin multi-language you can use `t` function in javascript, like this: `t('myPlugin_greeting', "Hello!")`.
662 +
663 +In frontend you get `t` from `HFS`, like this `const { t } = HFS`, while in backend you need to do
664 +`const { t } = await api.i18n(ctx)` inside an `async init` ([see example](https://github.com/rejetto/download-quota/blob/main/dist/plugin.js)).
665 +When possible, we suggest to do translation in the frontend.
666 +
667 +Now that your code is ready, to translate in some language you'll add files like `hfs-lang-XX.json` to your plugin (same folder as plugin.js),
668 where XX is the language code. The system is basically the same used to translate the rest of HFS,
669 and you can [read details here](https://github.com/rejetto/hfs/wiki/Translation).
670
671 In the previous example `myPlugin_greeting` is the name of the translation, while `Hello!` is the default text.
672 Instead of `myPlugin` use some text that you feel unique and no one else will use, to be sure that the same name
665 -is not used by another plugin, or even HFS in the future.
673 +is not used by another plugin, or even HFS in the future. We suggest to use your plugin's name in camelCase.
674
675 If you need to pass variables in the text, introduce a third parameter in the middle.
676 Eg: `HFS.t('myPlugin_filter_count', {n:filteredVariable}, "{n} filtered")`
@@ -688,7 +696,8 @@ If you want to override a text regardless of the language, use the special langu
696 - HFS.urlParams
697 - exports.beforePlugin + afterPlugin
698 - config.type: color
691 - - init can now return directly the unload function
699 + - init can now return directly the unload function
700 + - api.i18n
701 - 9.6 (v0.54.0)
702 - frontend event: showPlay
703 - api.addBlock
frontend/src/App.ts
+4 -2
@@ -6,12 +6,14 @@ import { BrowseFiles } from "./BrowseFiles"
6 import { Dialogs } from './dialog'
7 import useTheme from "./useTheme"
8 import { useSnapState } from './state'
9 -import { I18Nprovider } from './i18n'
9 +import i18n from './i18n'
10 import { proxy, useSnapshot } from "valtio"
11 import { Spinner } from "./components"
12 import { enforceStarting, getHFS, getPrefixUrl, loadScript } from '@hfs/shared'
13 import { Toasts } from './toasts'
14
15 +const { i18nWrapperProps } = i18n
16 +
17 function App() {
18 useTheme()
19 const { ready } = useSnapshot(pageState) // wait for all plugins to be loaded
@@ -21,7 +23,7 @@ function App() {
23 if (!ready)
24 return h(Spinner, { style: { margin: 'auto' } })
25 installScript()
24 - return h(I18Nprovider, {},
26 + return h('div', i18nWrapperProps(),
27 h(BrowserRouter, {},
28 h(NavigationExtractor, {},
29 h(Toasts),
frontend/src/Breadcrumbs.ts
+2 -1
@@ -5,9 +5,10 @@ import { createElement as h, Fragment, ReactElement } from 'react'
5 import { getPrefixUrl, hIcon } from './misc'
6 import { DirEntry, state, useSnapState } from './state'
7 import { usePath, reloadList } from './useFetchList'
8 -import { useI18N } from './i18n'
8 import { openFileMenu } from './fileMenu'
9 import { createFolder } from './upload'
10 +import i18n from './i18n'
11 +const { useI18N } = i18n
12
13 export function Breadcrumbs() {
14 const base = getPrefixUrl() + '/'
frontend/src/BrowseFiles.ts
+2 -1
@@ -16,10 +16,11 @@ import { useAuthorized } from './login'
16 import { acceptDropFiles } from './upload'
17 import { enqueueUpload } from './uploadQueue'
18 import _ from 'lodash'
19 -import { t, useI18N } from './i18n'
19 import { makeOnClickOpen, openFileMenu } from './fileMenu'
20 import { ClipBar } from './clip'
21 import { fileShow, getShowComponent } from './show'
22 +import i18n from './i18n'
23 +const { t, useI18N } = i18n
24
25 export const MISSING_PERM = "Missing permission"
26
frontend/src/FilterBar.ts
+2 -1
@@ -2,9 +2,10 @@ import { state, useSnapState } from './state'
2 import { createElement as h, useEffect, useState } from 'react'
3 import { useDebounce } from 'usehooks-ts'
4 import { Checkbox } from './components'
5 -import { useI18N } from './i18n'
5 import { usePath } from './useFetchList'
6 import { getHFS, with_ } from './misc'
7 +import i18n from './i18n'
8 +const { useI18N } = i18n
9
10 export function FilterBar() {
11 const { list, filteredList, selected, patternFilter, showFilter } = useSnapState()
frontend/src/Head.ts
+2 -1
@@ -7,7 +7,8 @@ import { useSnapState } from './state'
7 import { MenuPanel } from './menu'
8 import { Breadcrumbs } from './Breadcrumbs'
9 import { FilterBar } from './FilterBar'
10 -import { useI18N } from './i18n'
10 +import i18n from './i18n'
11 +const { useI18N } = i18n
12
13 export function Head() {
14 return h('header', {},
frontend/src/UserPanel.ts
+2 -1
@@ -8,7 +8,8 @@ import { apiCall } from '@hfs/shared/api'
8 import { logout } from './login'
9 import { Btn, CustomCode } from './components'
10 import { formatTimestamp, hIcon, fallbackToBasicAuth, working } from './misc'
11 -import { t } from './i18n'
11 +import i18n from './i18n'
12 +const { t } = i18n
13
14 export default function showUserPanel() {
15 const { close } = newDialog({
frontend/src/clip.ts
+2 -1
@@ -1,7 +1,6 @@
1 import { createElement as h, Fragment } from 'react'
2 import { DirList, state, useSnapState } from './state'
3 import { Btn } from './components'
4 -import { t, useI18N } from './i18n'
4 import { alertDialog, toast } from './dialog'
5 import { useNavigate } from 'react-router-dom'
6 import { dirname, HTTP_MESSAGES, xlate } from '../../src/cross'
@@ -9,6 +8,8 @@ import { apiCall } from '@hfs/shared/api'
8 import { reloadList, usePath } from './useFetchList'
9 import _ from 'lodash'
10 import { hfsEvent } from './misc'
11 +import i18n from './i18n'
12 +const { t, useI18N } = i18n
13
14 export function ClipBar() {
15 const { clip, props } = useSnapState()
frontend/src/components.ts
+2 -1
@@ -7,7 +7,8 @@ import {
7 useMemo, useState, ComponentPropsWithoutRef, LabelHTMLAttributes, useRef
8 } from 'react'
9 import _ from 'lodash'
10 -import { t } from './i18n'
10 +import i18n from './i18n'
11 +const { t } = i18n
12
13 export function Spinner(props: any) {
14 return hIcon('spinner', { className:'spinner', ...props })
frontend/src/dialog.ts
+2 -1
@@ -6,10 +6,11 @@ import './dialog.css'
6 import { newDialog, closeDialog, DialogOptions, dialogsDefaults } from '@hfs/shared/dialogs'
7 import _ from 'lodash'
8 import { useInterval } from 'usehooks-ts'
9 -import { t } from './i18n'
9 import { err2msg, isCtrlKey, pendingPromise, Promisable } from './misc'
10 export * from '@hfs/shared/dialogs'
11 export { toast } from './toasts'
12 +import i18n from './i18n'
13 +const { t } = i18n
14
15 _.merge(dialogsDefaults, { closableProps: { 'aria-label': t`Close` } })
16
frontend/src/fileMenu.ts
+2 -1
@@ -1,4 +1,3 @@
1 -import { t, useI18N } from './i18n'
1 import {
2 dontBotherWithKeys, formatBytes, getHFS, hfsEvent, hIcon, newDialog, prefix, with_, working,
3 pathEncode, closeDialog, anyDialogOpen, Falsy, operationSuccessful
@@ -15,6 +14,8 @@ import { apiCall, useApi } from '@hfs/shared/api'
14 import { inputComment } from './upload'
15 import { cut } from './clip'
16 import { loginDialog } from './login'
17 +import i18n from './i18n'
18 +const { t, useI18N } = i18n
19
20 interface FileMenuEntry {
21 id?: string
frontend/src/i18n.ts
+3 -123
@@ -1,124 +1,4 @@
1 -// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
1 +import { i18nFromTranslations } from '../../src/i18n'
2 +import { getHFS } from '@hfs/shared'
3
3 -import { findDefined, getHFS } from './misc'
4 -import { createElement as h } from 'react'
5 -import { proxy, useSnapshot } from 'valtio'
6 -import { watch } from 'valtio/utils'
7 -
8 -const translations = getHFS().lang || {} // all dictionaries
9 -const state = proxy({
10 - embedded: '',
11 - langs: getLangs()
12 -})
13 -const searchLangs: string[] = []
14 -watch(get => {
15 - const snapshot = get(state)
16 - searchLangs.splice(0, Infinity, 'all', ...snapshot.langs, snapshot.embedded) // replace completely
17 -})
18 -
19 -const warns = new Set() // avoid duplicates
20 -
21 -export function getLangs() {
22 - return Object.keys(translations)
23 -}
24 -
25 -// the hook ensures translation is refreshed when language changes
26 -export function useI18N() {
27 - useSnapshot(state)
28 - return { t }
29 -}
30 -
31 -export function I18Nprovider({ embedded='en', ...props }) {
32 - state.embedded = embedded
33 - return h('div', { lang: state.langs[0] || embedded, ...props })
34 -}
35 -
36 -// If one of the keys is an "id", that should be the first. If one of the keys should work as a fallback, that should be the last. Use 'fallback' parameter if you don't want the fallback to work as a key.
37 -export function t(keyOrTpl: string | string[] | TemplateStringsArray, params?: any, fallback?: string) {
38 - if (!keyOrTpl)
39 - return ''
40 - // memoize?
41 - const keys = isTemplateStringsArray(keyOrTpl) ? [(fallback ??= keyOrTpl[0] as string)]
42 - : Array.isArray(keyOrTpl) ? keyOrTpl : [keyOrTpl]
43 - if (typeof params === 'string' && !fallback) {
44 - fallback = params
45 - params = null
46 - }
47 - let found
48 - let selectedLang = '' // keep track of where we find the translation
49 - const { embedded, langs } = state
50 - for (const key of keys) {
51 - found = findDefined(searchLangs, lang => translations[selectedLang=lang]?.translate?.[key])
52 - if (found) break
53 - if (!warns.has(key) && langs.length && langs[0] !== embedded) {
54 - warns.add(key)
55 - console.debug("miss i18n:", key)
56 - }
57 - }
58 - if (!found) {
59 - found = fallback || keys[keys.length - 1]
60 - selectedLang = embedded
61 - }
62 - return Array.from(tokenizer(found)).map(([s,inside]) => {
63 - if (!inside) return s
64 - const [k,cmd,rest] = s.split(',')
65 - if (!params) throw "missing params on " + keys[0]
66 - const v = params[k]
67 - if (cmd === 'plural')
68 - return plural(v, rest)
69 - return v || v === 0 ? v : ''
70 - }).join('')
71 -
72 - function plural(v: any, rest: string) {
73 - const plural = !Intl.PluralRules ? 'other'
74 - : new Intl.PluralRules(selectedLang || embedded).select(Number(v))
75 - let other = ''
76 - let pickNext = false
77 - let collectOther = false
78 - for (const [s,inside] of tokenizer(rest)) {
79 - if (pickNext)
80 - return pick(s)
81 - if (collectOther) {
82 - other = s
83 - collectOther = false
84 - }
85 - if (inside) continue
86 - const selectors = s.trim().split(/\s+/)
87 - pickNext = selectors.some(sel =>
88 - sel[0] === '=' && v === Number(sel.slice(1))
89 - || sel === plural )
90 - collectOther = !pickNext && selectors.includes('other')
91 - }
92 - return pick(other)
93 -
94 - function pick(s: string) {
95 - return s.replace('#', String(v))
96 - }
97 - }
98 -}
99 -
100 -function* tokenizer(s:string): Generator<[string,boolean]> {
101 - let ofs = 0
102 - while (1) {
103 - const open = s.indexOf('{', ofs)
104 - if (open < 0) break
105 - yield [s.slice(ofs, open), false]
106 - let stack = 1
107 - ofs = open + 1
108 - while (stack && ofs < s.length) {
109 - if (s[ofs] === '{')
110 - stack++
111 - else if (s[ofs] === '}')
112 - stack--
113 - ofs++
114 - }
115 - if (stack)
116 - return console.debug('tokenizer: unclosed') // invalid, abort
117 - yield [s.slice(open + 1, ofs-1), true]
118 - }
119 - yield [s.slice(ofs), false]
120 -}
121 -
122 -function isTemplateStringsArray(x: any): x is TemplateStringsArray {
123 - return x?.raw && Array.isArray(x)
124 -}
\ No newline at end of file
4 +export default i18nFromTranslations(getHFS().lang || {})
\ No newline at end of file
frontend/src/login.ts
+2 -1
@@ -8,9 +8,10 @@ import {
8 HTTP_CONFLICT, HTTP_UNAUTHORIZED, CFG,
9 } from './misc'
10 import { createElement as h, Fragment, useEffect, useRef } from 'react'
11 -import { t, useI18N } from './i18n'
11 import { reloadList } from './useFetchList'
12 import { Checkbox, CustomCode } from './components'
13 +import i18n from './i18n'
14 +const { t, useI18N } = i18n
15
16 async function login(username:string, password:string, extra?: object) {
17 const stopWorking = working()
frontend/src/menu.ts
+2 -1
@@ -17,9 +17,10 @@ import { uploadState } from './uploadQueue'
17 import { useSnapshot } from 'valtio'
18 import { apiCall } from '@hfs/shared/api'
19 import { reloadList } from './useFetchList'
20 -import { t, useI18N } from './i18n'
20 import { cut } from './clip'
21 import { Btn, BtnProps, CustomCode } from './components'
22 +import i18n from './i18n'
23 +const { t, useI18N } = i18n
24
25 export function MenuPanel() {
26 const { showFilter, remoteSearch, stopSearch, searchManuallyInterrupted, selected, props, searchOptions } = useSnapState()
frontend/src/misc.ts
+2 -1
@@ -9,7 +9,6 @@ import * as cross from '../../src/cross'
9 import * as shared from '@hfs/shared'
10 import { apiCall, getNotifications, useApi } from '@hfs/shared/api'
11 import { DirEntry, state, useSnapState } from './state'
12 -import { getLangs, t } from './i18n'
12 import * as dialogLib from './dialog'
13 import _ from 'lodash'
14 import { reloadList } from './useFetchList'
@@ -19,6 +18,8 @@ import { uploadState } from './uploadQueue'
18 import { fileShow } from './show'
19 import { debounceAsync } from '../../src/debounceAsync'
20 export * from '@hfs/shared'
21 +import i18n from './i18n'
22 +const { t, getLangs } = i18n
23
24 export function err2msg(err: number | Error) {
25 return typeof err === 'number' ? HTTP_MESSAGES[err]
frontend/src/options.ts
+2 -1
@@ -6,8 +6,9 @@ import { createElement as h } from 'react'
6 import { Checkbox, FlexV, Select } from './components'
7 import { getHFS, hIcon, MAX_TILE_SIZE, SORT_BY_OPTIONS, THEME_OPTIONS } from './misc'
8 import { MenuLink } from './menu'
9 -import { t } from './i18n'
9 import _ from 'lodash'
10 +import i18n from './i18n'
11 +const { t } = i18n
12
13 export function showOptions (){
14 newDialog({
frontend/src/show.ts
+2 -1
@@ -8,11 +8,12 @@ import { useEventListener, useWindowSize } from 'usehooks-ts'
8 import { EntryDetails, useMidnight } from './BrowseFiles'
9 import { Btn, FlexV, iconBtn, Spinner } from './components'
10 import { openFileMenu } from './fileMenu'
11 -import { t, useI18N } from './i18n'
11 import { alertDialog, toast } from './dialog'
12 import _ from 'lodash'
13 import { getId3Tags } from './id3'
14 import { subscribeKey } from 'valtio/utils'
15 +import i18n from './i18n'
16 +const { t, useI18N } = i18n
17
18 enum ZoomMode {
19 fullWidth,
frontend/src/upload.ts
+2 -1
@@ -13,12 +13,13 @@ import { reloadList } from './useFetchList'
13 import { apiCall } from '@hfs/shared/api'
14 import { state, useSnapState } from './state'
15 import { Link } from 'react-router-dom'
16 -import { t } from './i18n'
16 import { LinkClosingDialog } from './fileMenu'
17 import {
18 abortCurrentUpload, enqueueUpload, getFilePath, normalizeAccept, resetCounters, resetReloadOnClose,
19 simulateBrowserAccept, ToUpload, uploadState
20 } from './uploadQueue'
21 +import i18n from './i18n'
22 +const { t } = i18n
23
24 const renameEnabled = getHFS().dontOverwriteUploading
25
frontend/src/uploadQueue.ts
+2 -1
@@ -6,13 +6,14 @@ import {
6 import { state } from './state'
7 import { getNotifications } from '@hfs/shared/api'
8 import { subscribeKey } from 'valtio/utils'
9 -import { t } from './i18n'
9 import { alertDialog, confirmDialog, toast } from './dialog'
10 import { reloadList } from './useFetchList'
11 import { proxy, ref, snapshot, subscribe } from 'valtio'
12 import { createElement as h } from 'react'
13 import _ from 'lodash'
14 import { UploadStatus } from './upload'
15 +import i18n from './i18n'
16 +const { t } = i18n
17
18 export interface ToUpload { file: File, comment?: string, name?: string, to?: string, error?: string }
19 export const uploadState = proxy<{
frontend/src/useFetchList.ts
+2 -1
@@ -11,10 +11,11 @@ import {
11 hfsEvent, LIST, urlParams, xlate, objFromKeys,
12 HTTP_MESSAGES, HTTP_METHOD_NOT_ALLOWED, HTTP_UNAUTHORIZED,
13 } from './misc'
14 -import { t } from './i18n'
14 import { useLocation, useNavigate } from 'react-router-dom'
15 import { closeLoginDialog } from './login'
16 import { fileShow, getShowComponent } from './show'
17 +import i18n from './i18n'
18 +const { t } = i18n
19
20 export function usePath() {
21 return useLocation().pathname
src/i18n.ts new
+125
@@ -0,0 +1,125 @@
1 +// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2 +
3 +import { proxy, useSnapshot } from 'valtio'
4 +import { watch } from 'valtio/utils'
5 +
6 +export function i18nFromTranslations(translations: Record<string, any>) {
7 + const state = proxy({
8 + embedded: '',
9 + translations, // all dictionaries
10 + })
11 + const searchLangs: string[] = []
12 + watch(get => {
13 + const snapshot = get(state)
14 + searchLangs.splice(0, Infinity, 'all', ...Object.keys(snapshot.translations), snapshot.embedded) // replace completely
15 + })
16 +
17 + const warns = new Set() // avoid duplicates
18 +
19 + function getLangs() {
20 + return Object.keys(state.translations)
21 + }
22 +
23 +// If one of the keys is an "id", that should be the first. If one of the keys should work as a fallback, that should be the last. Use 'fallback' parameter if you don't want the fallback to work as a key.
24 + function t(keyOrTpl: string | string[] | TemplateStringsArray, params?: any, fallback?: string) {
25 + if (!keyOrTpl)
26 + return ''
27 + // memoize?
28 + const keys = isTemplateStringsArray(keyOrTpl) ? [(fallback ??= keyOrTpl[0] as string)]
29 + : Array.isArray(keyOrTpl) ? keyOrTpl : [keyOrTpl]
30 + if (typeof params === 'string' && !fallback) {
31 + fallback = params
32 + params = null
33 + }
34 + let found
35 + let selectedLang = '' // keep track of where we find the translation
36 + const { embedded } = state
37 + const langs = Object.keys(state.translations)
38 + for (const key of keys) {
39 + for (const lang of searchLangs)
40 + if (found = state.translations[selectedLang=lang]?.translate?.[key]) break
41 + if (!warns.has(key) && langs.length && langs[0] !== embedded) {
42 + warns.add(key)
43 + console.debug("miss i18n:", key)
44 + }
45 + }
46 + if (!found) {
47 + found = fallback || keys[keys.length - 1]
48 + selectedLang = embedded
49 + }
50 + return Array.from(tokenizer(found)).map(([s,inside]) => {
51 + if (!inside) return s
52 + const [k,cmd,rest] = s.split(',')
53 + if (!params) throw "missing params on " + keys[0]
54 + const v = k && params[k]
55 + if (cmd === 'plural' && rest)
56 + return plural(v, rest)
57 + return v || v === 0 ? v : ''
58 + }).join('')
59 +
60 + function plural(v: any, rest: string) {
61 + const plural = !Intl.PluralRules ? 'other'
62 + : new Intl.PluralRules(selectedLang || embedded).select(Number(v))
63 + let other = ''
64 + let pickNext = false
65 + let collectOther = false
66 + for (const [s,inside] of tokenizer(rest)) {
67 + if (pickNext)
68 + return pick(s)
69 + if (collectOther) {
70 + other = s
71 + collectOther = false
72 + }
73 + if (inside) continue
74 + const selectors = s.trim().split(/\s+/)
75 + pickNext = selectors.some(sel =>
76 + sel[0] === '=' && v === Number(sel.slice(1))
77 + || sel === plural )
78 + collectOther = !pickNext && selectors.includes('other')
79 + }
80 + return pick(other)
81 +
82 + function pick(s: string) {
83 + return s.replace('#', String(v))
84 + }
85 + }
86 + }
87 +
88 + function* tokenizer(s:string): Generator<[string,boolean]> {
89 + let ofs = 0
90 + while (1) {
91 + const open = s.indexOf('{', ofs)
92 + if (open < 0) break
93 + yield [s.slice(ofs, open), false]
94 + let stack = 1
95 + ofs = open + 1
96 + while (stack && ofs < s.length) {
97 + if (s[ofs] === '{')
98 + stack++
99 + else if (s[ofs] === '}')
100 + stack--
101 + ofs++
102 + }
103 + if (stack)
104 + return console.debug('tokenizer: unclosed') // invalid, abort
105 + yield [s.slice(open + 1, ofs-1), true]
106 + }
107 + yield [s.slice(ofs), false]
108 + }
109 +
110 + function isTemplateStringsArray(x: any): x is TemplateStringsArray {
111 + return x?.raw && Array.isArray(x)
112 + }
113 +
114 + return {
115 + t,
116 + getLangs,
117 + i18nWrapperProps(embedded='en') {
118 + return { lang: getLangs()[0] || (state.embedded = embedded) }
119 + },
120 + useI18N() { // the hook ensures translation is refreshed when language changes
121 + useSnapshot(state)
122 + return { t }
123 + }
124 + }
125 +}
\ No newline at end of file
src/plugins.ts
+5
@@ -27,6 +27,8 @@ import { onProcessExit } from './first'
27 import { notifyClient } from './frontEndApis'
28 import { app } from './index'
29 import { addBlock } from './block'
30 +import { getLangData } from './lang'
31 +import { i18nFromTranslations } from './i18n'
32
33 export const PATH = 'plugins'
34 export const DISABLING_SUFFIX = '-disabled'
@@ -493,6 +495,9 @@ function watchPlugin(id: string, path: string) {
495 catch(e){ this.log(String(e)) }
496 })
497 },
498 + async i18n(ctx: any) {
499 + return i18nFromTranslations(await getLangData(ctx))
500 + },
501 })
502 const folder = dirname(module)
503 const { sections, unwatch } = watchLoadCustomHtml(folder)