chore: seminarhof customer project
This commit is contained in:
101
apps/app/src/i18n/config.ts
Normal file
101
apps/app/src/i18n/config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// i18n configuration — Paraglide JS (inlang). Messages live in `messages/*.json`
|
||||
// and are compiled to `src/paraglide/` by the Vite plugin (gitignored). This
|
||||
// module is the locale-plumbing entry point; `@/i18n/messages` exposes the typed
|
||||
// message functions (`m`).
|
||||
//
|
||||
// Add a language: drop `messages/<lang>.json` next to `en.json`, add the code to
|
||||
// `project.inlang/settings.json` `locales`, and recompile. Content is reachable
|
||||
// via the `/<lang>` URL prefix (e.g. `/fr`); the base locale (`en`) needs none.
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { locales, baseLocale, overwriteGetLocale, getLocale } from '../paraglide/runtime.js'
|
||||
|
||||
export const supportedLocales = locales
|
||||
export const defaultLocale = baseLocale
|
||||
export type Locale = (typeof locales)[number]
|
||||
// `getLocale()` reads the active locale (via the overwrite below) — used by
|
||||
// pages that format dates/values for the current language.
|
||||
export { getLocale }
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'seminarhof.language'
|
||||
|
||||
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])
|
||||
// Direction for a locale — RTL for Arabic/Hebrew/Farsi/Urdu, else LTR. Set this
|
||||
// on <html dir> at the root so the browser (and Mantine) mirror the layout.
|
||||
export function localeDir(locale: string = defaultLocale): 'rtl' | 'ltr' {
|
||||
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
const normalizeLanguage = (value: string | null | undefined): Locale | undefined => {
|
||||
const short = value?.slice(0, 2).toLowerCase()
|
||||
return supportedLocales.includes(short as Locale) ? (short as Locale) : undefined
|
||||
}
|
||||
|
||||
// Initial locale: persisted choice → browser → <html lang> → base. (Ported from
|
||||
// the old i18next init; germantax has no in-app switcher, locale is read once.)
|
||||
export function detectInitialLocale(): Locale {
|
||||
if (typeof window === 'undefined') return defaultLocale
|
||||
return (
|
||||
normalizeLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)) ??
|
||||
normalizeLanguage(window.navigator.language) ??
|
||||
normalizeLanguage(window.document.documentElement.lang) ??
|
||||
defaultLocale
|
||||
)
|
||||
}
|
||||
|
||||
// ── reactive locale store ────────────────────────────────────────────────────
|
||||
// Paraglide's `getLocale()` is a module-global, decoupled from React. We bridge
|
||||
// it to a tiny external store: `getLocale` reads `activeLocale`; components
|
||||
// subscribe via `useLocale()` (useSyncExternalStore) so `m.*()` re-renders on
|
||||
// switch — no page reload, no Paraglide `setLocale`, no app-specific context.
|
||||
// The app's own setLocale (persist + <html dir>) calls `setActiveLocale`.
|
||||
let activeLocale: Locale = defaultLocale
|
||||
const listeners = new Set<() => void>()
|
||||
overwriteGetLocale(() => activeLocale)
|
||||
|
||||
export function setActiveLocale(next: Locale): void {
|
||||
if (next === activeLocale) return
|
||||
activeLocale = next
|
||||
// Persist + reflect on <html lang> (ported from the old i18next languageChanged
|
||||
// handler), so a reload restores the choice.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, next)
|
||||
window.document.documentElement.lang = next
|
||||
}
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
// Resolve the initial locale once at startup (import side-effect, matching the
|
||||
// old i18n.ts init). Importing this module from the app entry runs it.
|
||||
activeLocale = detectInitialLocale()
|
||||
|
||||
// Subscribe a component to locale changes so `m.*()` re-renders on switch. The
|
||||
// codemod injects a bare `useLocale()` wherever `const { t } = useTranslation()`
|
||||
// used to live; it also returns the active locale + direction for components
|
||||
// that need them (e.g. the language switcher).
|
||||
export function useLocale(): { locale: Locale; dir: 'ltr' | 'rtl'; setLocale: (l: Locale) => void } {
|
||||
const locale = useSyncExternalStore<Locale>(subscribe, () => activeLocale, () => activeLocale)
|
||||
return { locale, dir: localeDir(locale), setLocale: setActiveLocale }
|
||||
}
|
||||
|
||||
// ── i18n-debug masking ───────────────────────────────────────────────────────
|
||||
// When enabled, every *translated* string is masked to block glyphs (█) so any
|
||||
// readable text left on screen is text that never went through a message (a
|
||||
// hardcoded/inlined string) — missing i18n at a glance. Toggle with `?i18n-debug`
|
||||
// in the URL or `localStorage['i18n-debug'] = '1'` (`I18N_DEBUG=1` for SSR).
|
||||
export function isI18nDebug(): boolean {
|
||||
if (typeof process !== 'undefined' && process.env?.I18N_DEBUG === '1') return true
|
||||
if (typeof window === 'undefined') return false
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('i18n-debug')) return params.get('i18n-debug') !== '0'
|
||||
return window.localStorage?.getItem('i18n-debug') === '1'
|
||||
}
|
||||
|
||||
/** Mask a rendered string when debug mode is on; otherwise pass it through. */
|
||||
export function maskI18n(s: string): string {
|
||||
return isI18nDebug() ? s.replace(/\S/g, '█') : s
|
||||
}
|
||||
12
apps/app/src/i18n/ident.ts
Normal file
12
apps/app/src/i18n/ident.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Single source of truth for turning an i18next dotted key (`landing.cards.x`)
|
||||
// into a Paraglide snake_case message identifier (`landing_cards_x`). Used by the
|
||||
// runtime resolver `mKey` for the unavoidable dynamic cases.
|
||||
export const identOf = (dotPath: string): string =>
|
||||
dotPath
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
||||
.replace(/[.\-\s]+/g, '_')
|
||||
.replace(/[^A-Za-z0-9_]/g, '')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.toLowerCase()
|
||||
57
apps/app/src/i18n/messages.ts
Normal file
57
apps/app/src/i18n/messages.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Typed message functions for the app. `m.landing_title()` returns a branded
|
||||
// I18nString, so it satisfies the @pikku/mantine i18n gate exactly where the old
|
||||
// `t('landing.title')` used to — no call-site boilerplate.
|
||||
//
|
||||
// Each message is wrapped once so i18n-debug masking (█) still works (parity with
|
||||
// the old i18next postProcessor). Wrapping the whole namespace forgoes Paraglide
|
||||
// per-message tree-shaking — fine here: locale files are KB and the app bundled
|
||||
// all of en.json under i18next anyway.
|
||||
import { m as _m } from '../paraglide/messages.js'
|
||||
import type { I18nString } from '@pikku/react'
|
||||
import { identOf } from './ident.js'
|
||||
import { maskI18n } from './config.js'
|
||||
|
||||
type BrandReturn<T> = T extends (...args: infer A) => unknown ? (...args: A) => I18nString : T
|
||||
type Branded<T> = { [K in keyof T]: BrandReturn<T[K]> }
|
||||
|
||||
const _raw = _m as unknown as Record<string, (args?: Record<string, unknown>) => string>
|
||||
const _wrapped: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(_raw)) {
|
||||
const fn = _raw[key]
|
||||
_wrapped[key] = typeof fn === 'function' ? (...args: unknown[]) => maskI18n((fn as (...a: unknown[]) => string)(...args)) : fn
|
||||
}
|
||||
|
||||
/** Branded, debug-maskable message namespace. Drop-in for `t('...')`. */
|
||||
export const m = _wrapped as unknown as Branded<typeof _m>
|
||||
|
||||
/**
|
||||
* Runtime key resolver for computed keys — `mKey(`landing.cards.${k}.title`)` or
|
||||
* `mKey(MAP[k])`. Flattens the dotted key to its snake_case token and calls the
|
||||
* message. Returns the raw key (and warns) on a miss, mirroring i18next's
|
||||
* graceful degradation. Prefer static `m.token(...)` wherever the key is known.
|
||||
*/
|
||||
export const mKey = (key: string, args?: Record<string, unknown>): I18nString => {
|
||||
const fn = _raw[identOf(key)]
|
||||
if (!fn) {
|
||||
if (typeof console !== 'undefined') console.warn(`[i18n] missing message for key "${key}"`)
|
||||
return maskI18n(key) as unknown as I18nString
|
||||
}
|
||||
return maskI18n(fn(args)) as unknown as I18nString
|
||||
}
|
||||
|
||||
/** Whether a message exists for a dotted key (replaces i18next `i18n.exists`). */
|
||||
export const mExists = (key: string): boolean => !!_raw[identOf(key)]
|
||||
|
||||
/**
|
||||
* Resolves an i18next `returnObjects` array (stored as indexed keys `prefix.0`,
|
||||
* `prefix.1`, …) back into a list. Returns messages until the first gap.
|
||||
*/
|
||||
export const mList = (keyPrefix: string, args?: Record<string, unknown>): I18nString[] => {
|
||||
const out: I18nString[] = []
|
||||
for (let i = 0; ; i++) {
|
||||
const fn = _raw[identOf(`${keyPrefix}.${i}`)]
|
||||
if (!fn) break
|
||||
out.push(maskI18n(fn(args)) as unknown as I18nString)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user