main
ts 129 lines 4.68 KB
Raw
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 const ANY_LANGUAGE = 'all'
7
8 // this code is in the backend to give the chance to plugins to translate http's body
9 export function i18nFromTranslations(translations: Record<string, any>, embedded='en') {
10 const state = proxy({
11 embedded,
12 disabled: false,
13 translations, // all dictionaries
14 })
15 const searchLangs: string[] = []
16 watch(get => {
17 const snap = get(state)
18 searchLangs.splice(0, Infinity, ANY_LANGUAGE, ...snap.disabled ? [] : Object.keys(snap.translations), snap.embedded) // replace the array completely
19 })
20
21 const warns = new Set() // avoid duplicates
22
23 function getLangs() {
24 return Object.keys(state.translations)
25 }
26
27 // 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.
28 function t(keyOrTpl: string | string[] | TemplateStringsArray, params?: any, fallback?: string) {
29 if (!keyOrTpl)
30 return ''
31 // memoize?
32 const keys = isTemplateStringsArray(keyOrTpl) ? [(fallback ??= keyOrTpl[0] as string)]
33 : Array.isArray(keyOrTpl) ? keyOrTpl : [keyOrTpl]
34 if (typeof params === 'string' && !fallback) {
35 fallback = params
36 params = null
37 }
38 let found
39 let selectedLang = '' // keep track of where we find the translation
40 const { embedded } = state
41 const langs = Object.keys(state.translations)
42 for (const key of keys) {
43 for (const lang of searchLangs)
44 if (found = state.translations[selectedLang=lang]?.translate?.[key]) break
45 if (found) break
46 if (!warns.has(key) && langs.length && langs[0] !== embedded) {
47 warns.add(key)
48 console.debug("i18n miss:", key)
49 }
50 }
51 if (!found) {
52 found = fallback || keys[keys.length - 1]
53 selectedLang = embedded
54 }
55 return Array.from(tokenizer(found)).map(([s,inside]) => {
56 if (!inside) return s
57 const [k,cmd,rest] = s.split(',').map(x => x.trim())
58 if (!params) throw "missing params on " + keys[0]
59 const v = k && params[k]
60 if (cmd === 'plural' && rest)
61 return plural(v, rest)
62 return v || v === 0 ? v : ''
63 }).join('')
64
65 function plural(v: any, rest: string) {
66 const plural = !Intl.PluralRules ? 'other'
67 : new Intl.PluralRules(selectedLang || embedded).select(Number(v))
68 let other = ''
69 let pickNext = false
70 let collectOther = false
71 for (const [s,inside] of tokenizer(rest)) {
72 if (pickNext)
73 return pick(s)
74 if (collectOther) {
75 other = s
76 collectOther = false
77 }
78 if (inside) continue
79 const selectors = s.trim().split(/\s+/)
80 pickNext = selectors.some(sel =>
81 sel[0] === '=' && v === Number(sel.slice(1))
82 || sel === plural )
83 collectOther = !pickNext && selectors.includes('other')
84 }
85 return pick(other)
86
87 function pick(s: string) {
88 return s.replace('#', String(v))
89 }
90 }
91 }
92
93 function* tokenizer(s:string): Generator<[string,boolean]> {
94 let ofs = 0
95 while (1) {
96 const open = s.indexOf('{', ofs)
97 if (open < 0) break
98 yield [s.slice(ofs, open), false]
99 let stack = 1
100 ofs = open + 1
101 while (stack && ofs < s.length) {
102 if (s[ofs] === '{')
103 stack++
104 else if (s[ofs] === '}')
105 stack--
106 ofs++
107 }
108 if (stack)
109 return console.debug('Tokenizer: unclosed') // invalid, abort
110 yield [s.slice(open + 1, ofs-1), true]
111 }
112 yield [s.slice(ofs), false]
113 }
114
115 function isTemplateStringsArray(x: any): x is TemplateStringsArray {
116 return x?.raw && Array.isArray(x)
117 }
118
119 return {
120 t,
121 state,
122 getLangs,
123 i18nWrapperProps: () => ({ lang: getLangs()[0] || state.embedded }),
124 useI18N() { // the hook ensures translation is refreshed when language changes
125 useSnapshot(state)
126 return { t }
127 }
128 }
129 }