Newer
Older
navi-1 / webclient / src / i18n / index.js
// Tiny reactive i18n for the SPA (vue-i18n is deliberately not pulled in, per
// the gnexus handbook): a locale ref, a t() with {param} interpolation and
// Slavic plural forms, and flat per-area message dictionaries. The language is
// the backend's `locale_effective` (settings override → auth account → en)
// whenever there is an account, and this browser's own choice otherwise.
import { ref, watch } from 'vue'

import en from './messages/en.js'
import uk from './messages/uk.js'
import ru from './messages/ru.js'

const messages = { en, uk, ru }

export const SUPPORTED_LOCALES = Object.keys(messages)

// With sign-in switched off there is no account to follow, so the choice
// belongs to this browser and is kept here.
export const LOCALE_STORAGE_KEY = 'navi.locale'

export const locale = ref('en')

// Language tags normalize to their 2-letter prefix ("en-US" → "en"); anything
// the dictionaries do not ship falls back to English.
export function normalizeTag(tag) {
  const prefix = String(tag || '').trim().toLowerCase().split(/[-_]/)[0]
  return messages[prefix] ? prefix : null
}

export function setLocale(tag) {
  locale.value = normalizeTag(tag) || 'en'
}

export function storedLocale() {
  try {
    return normalizeTag(localStorage.getItem(LOCALE_STORAGE_KEY))
  } catch {
    // Storage blocked (private window) — English is a working answer.
    return null
  }
}

export function rememberLocale(tag) {
  try {
    localStorage.setItem(LOCALE_STORAGE_KEY, tag)
  } catch {
    // Nothing to do: the choice then lasts for this page view only.
  }
}

watch(locale, (lang) => {
  if (typeof document !== 'undefined') document.documentElement.lang = lang
})

export function pluralCategory(lang, n) {
  // ru/uk: n%10==1 (except 11) → one; 2-4 (except 12-14) → few; else many
  const mod10 = n % 10
  const mod100 = n % 100
  if (lang === 'ru' || lang === 'uk') {
    if (mod10 === 1 && mod100 !== 11) return 'one'
    if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) return 'few'
    return 'many'
  }
  return n === 1 ? 'one' : 'other'
}

function interpolate(text, params) {
  return text.replace(/\{(\w+)\}/g, (match, key) =>
    params && key in params ? String(params[key]) : match
  )
}

function lookup(slug) {
  return messages[locale.value]?.[slug] ?? messages.en[slug]
}

export function t(slug, params) {
  let text = lookup(slug)
  if (text != null && typeof text === 'object') {
    // Plural dictionary {one, few, many, other}; a missing form falls to other.
    const n = Number(params?.n ?? 0)
    text = text[pluralCategory(locale.value, n)] ?? text.other
  }
  return text != null ? interpolate(String(text), params) : slug
}