// UI strings (D-20, D-24). The resolved backend::lang bundle is loaded from // the public GET /lang before anything else at startup. A key that is not in // the bundle renders as the key itself, mirroring phrasebook's missing-key // fallback. Every message is a CLDR form map; plural messages pick their form // with Intl.PluralRules for the bundle's locale and fall back to "other". // Placeholders use phrasebook's :name syntax with :Name and :NAME variants, // and interpolate() replaces them exactly like phrasebook.interpolate. import { readonly, ref } from 'vue' import { api } from '../api/client' import type { LangBundle, MessageForms } from '../api/types' export type MessageParams = Record const bundle = ref({}) const locale = ref(typeof document === 'undefined' ? 'pl' : document.documentElement.lang || 'pl') /** The locale the loaded bundle resolved to (meta.locale of GET /lang). */ export const currentLocale = readonly(locale) export function setBundle(next: LangBundle, nextLocale?: string): void { bundle.value = next if (nextLocale) { locale.value = nextLocale if (typeof document !== 'undefined') { document.documentElement.lang = nextLocale } } } /** Loads the backend::lang bundle for the admin's locale. Public endpoint. */ export async function loadStrings(): Promise { const { data, response } = await api.GET('/lang') if (!response.ok || !data) { return } setBundle(data.data, data.meta.locale) } /** Upper-cases the first code point, like phrasebook's ucfirst. */ function ucfirst(value: string): string { const first = value.codePointAt(0) if (first === undefined) { return value } const head = String.fromCodePoint(first) return head.toUpperCase() + value.slice(head.length) } /** * Replaces :name, :Name and :NAME placeholders. Longer placeholders are * replaced first (stable for equal lengths), so :names never loses to :name. */ export function interpolate(text: string, params: MessageParams = {}): string { if (text === '') { return text } const replacements: Array<[string, string]> = [] for (const [rawName, raw] of Object.entries(params)) { const name = rawName.replace(/^:/, '') if (name === '') { continue } const value = String(raw) replacements.push([`:${ucfirst(name)}`, ucfirst(value)]) replacements.push([`:${name.toUpperCase()}`, value.toUpperCase()]) replacements.push([`:${name}`, value]) } replacements.sort((a, b) => b[0].length - a[0].length) let out = text for (const [placeholder, value] of replacements) { out = out.split(placeholder).join(value) } return out } const pluralRules = new Map() /** CLDR plural category of count in the bundle locale; "other" if unknown. */ export function pluralCategory(count: number, forLocale: string = locale.value): string { let rules = pluralRules.get(forLocale) if (rules === undefined) { try { rules = new Intl.PluralRules(forLocale) } catch { rules = null } pluralRules.set(forLocale, rules) } return rules ? rules.select(count) : 'other' } /** * Renders one message (CLDR forms). With a count the plural form is chosen * and :count is available to the text; params may override it. */ export function message(forms: MessageForms | null | undefined, count?: number, params: MessageParams = {}): string { if (!forms) { return '' } if (count === undefined) { return interpolate(forms.other ?? '', params) } const text = forms[pluralCategory(count)] ?? forms.other ?? '' return interpolate(text, { count, ...params }) } /** A bundle string by key; the key itself when it is not loaded. */ export function t(key: string, params: MessageParams = {}): string { const text = bundle.value[key]?.other if (text === undefined) { return key } return interpolate(text, params) } /** A plural bundle string by key; the key itself when it is not loaded. */ export function tc(key: string, count: number, params: MessageParams = {}): string { const forms = bundle.value[key] if (!forms) { return key } return message(forms, count, params) }