Files
summercms/admin/src/app/i18n.ts
Jakub Zych 5244a37b00 feat(quick-261016-0fm): language switcher in the user menu
- loadStrings sends the chosen language as ?locale= and keeps
  meta.locales as availableLocales
- the user menu lists the admin locales, each named in its own language;
  choosing one saves it through PUT /account/preferences, caches it and
  reloads the page
- boot loads the strings in the cached language; me() reloads them when
  the stored preference differs
2026-10-16 11:35:08 +02:00

134 lines
4.5 KiB
TypeScript

// 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<string, string | number>
const bundle = ref<LangBundle>({})
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)
const locales = ref<string[]>([])
/** The supported admin locales (meta.locales of GET /lang). */
export const availableLocales = readonly(locales)
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. Public endpoint. preferred (the admin's
* chosen language) is sent as ?locale=; the server uses it only when it is
* a supported admin locale, else the request locale.
*/
export async function loadStrings(preferred?: string): Promise<void> {
const { data, response } = await api.GET('/lang', preferred ? { params: { query: { locale: preferred } } } : {})
if (!response.ok || !data) {
return
}
locales.value = Array.isArray(data.meta.locales) ? [...data.meta.locales] : []
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<string, Intl.PluralRules | null>()
/** 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)
}