| 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 | } |