mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(i18n): hand Intl and the document the real language tag for br and gr
TREK's codes 'br' (Brazilian Portuguese) and 'gr' (Greek) went straight into <html lang> and into ten Intl calls. 'br' is Breton, so Brazilian users got Breton month names and screen readers announced the page as Breton; 'gr' is no language at all, so Greek users got the default locale. getIntlLanguage maps both, and every one of those sites goes through it.
This commit is contained in:
@@ -113,7 +113,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
const [strings, setStrings] = useState<TranslationStrings>(en)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.lang = getIntlLanguage(language)
|
||||
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
|
||||
}, [language])
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import MIconBtn from '../../components/MIconBtn'
|
||||
import { A2_TO_A3 } from '../../../pages/atlas/atlasModel'
|
||||
import type { BucketItem } from '../../../pages/atlas/atlasModel'
|
||||
import type { AtlasController } from './atlasController'
|
||||
import { getIntlLanguage } from '@trek/shared'
|
||||
|
||||
interface MAtlasBucketSheetProps {
|
||||
atlas: AtlasController
|
||||
@@ -69,7 +70,7 @@ export default function MAtlasBucketSheet({ atlas, open, onClose }: MAtlasBucket
|
||||
|
||||
const fmtTarget = (targetDate: string): string => {
|
||||
const [y, m] = targetDate.split('-')
|
||||
return m ? new Date(Number(y), Number(m) - 1).toLocaleDateString(language, { month: 'short', year: 'numeric' }) : y
|
||||
return m ? new Date(Number(y), Number(m) - 1).toLocaleDateString(getIntlLanguage(language), { month: 'short', year: 'numeric' }) : y
|
||||
}
|
||||
|
||||
const itemSub = (item: BucketItem): string => {
|
||||
@@ -257,7 +258,7 @@ function MMonthYearField({ value, onChange, placeholder, language, t }: {
|
||||
const [year, setYear] = useState(selYear ?? new Date().getFullYear())
|
||||
|
||||
const display = selYear
|
||||
? new Date(selYear, (selMonth ?? 1) - 1).toLocaleDateString(language, { month: 'long', year: 'numeric' })
|
||||
? new Date(selYear, (selMonth ?? 1) - 1).toLocaleDateString(getIntlLanguage(language), { month: 'long', year: 'numeric' })
|
||||
: ''
|
||||
|
||||
return (
|
||||
@@ -288,7 +289,7 @@ function MMonthYearField({ value, onChange, placeholder, language, t }: {
|
||||
onClick={() => { onChange(`${year}-${String(m + 1).padStart(2, '0')}`); setOpen(false) }}
|
||||
className={`rounded-[10px] py-2 text-[0.71875rem] font-bold capitalize ${on ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'}`}
|
||||
>
|
||||
{new Date(2000, m, 1).toLocaleDateString(language, { month: 'short' })}
|
||||
{new Date(2000, m, 1).toLocaleDateString(getIntlLanguage(language), { month: 'short' })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useAuthStore } from '../../../store/authStore'
|
||||
import { canManageDocSync } from '../../../components/Files/docsync/useDocSync'
|
||||
import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered'
|
||||
import type { Day, Trip } from '../../../types'
|
||||
import { getIntlLanguage } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Mobile trip screen frame. Owns the chrome the design shares across every
|
||||
@@ -190,7 +191,7 @@ function dayChipLabel(day: Day, language: string, fallback: string): string {
|
||||
if (day.date) {
|
||||
const date = new Date(`${day.date.slice(0, 10)}T00:00:00`)
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return `${new Intl.DateTimeFormat(language, { weekday: 'short' }).format(date)} ${date.getDate()}`
|
||||
return `${new Intl.DateTimeFormat(getIntlLanguage(language), { weekday: 'short' }).format(date)} ${date.getDate()}`
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useToast } from '../components/shared/Toast'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import AtlasCountryPlaces from '../components/Atlas/AtlasCountryPlaces'
|
||||
import { getIntlLanguage } from '@trek/shared'
|
||||
|
||||
// Fixed ids rather than useId: the page body stays free of hooks, and there is only one atlas.
|
||||
const COUNTRY_DIALOG_TITLE = 'atlas-country-dialog-title'
|
||||
@@ -421,7 +422,7 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
placeholder={t('atlas.month')}
|
||||
options={[
|
||||
{ value: '0', label: t('common.none') },
|
||||
...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
|
||||
...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(getIntlLanguage(language), { month: 'long' }) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
@@ -585,7 +586,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
|
||||
<span className="text-xs font-semibold text-center leading-tight" style={{ color: tp, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</span>
|
||||
{item.target_date && (() => {
|
||||
const [y, m] = item.target_date.split('-')
|
||||
const label = m ? new Date(Number(y), Number(m) - 1).toLocaleString(language, { month: 'short', year: 'numeric' }) : y
|
||||
const label = m ? new Date(Number(y), Number(m) - 1).toLocaleString(getIntlLanguage(language), { month: 'short', year: 'numeric' }) : y
|
||||
return <span className="text-[9px] mt-0.5 text-center" style={{ color: tf }}>{label}</span>
|
||||
})()}
|
||||
{!item.target_date && item.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</span>}
|
||||
@@ -611,7 +612,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
|
||||
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', background: 'none', border: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
<Check size={9} />
|
||||
{new Date(item.visited_at).toLocaleDateString(language)}
|
||||
{new Date(item.visited_at).toLocaleDateString(getIntlLanguage(language))}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
@@ -619,7 +620,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
|
||||
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
|
||||
>
|
||||
<Check size={9} />
|
||||
{new Date(item.visited_at).toLocaleDateString(language)}
|
||||
{new Date(item.visited_at).toLocaleDateString(getIntlLanguage(language))}
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
@@ -707,7 +708,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect value={String(bucketPoiMonth)} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
|
||||
options={[{ value: '0', label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
|
||||
options={[{ value: '0', label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(getIntlLanguage(language), { month: 'short' }) }))]} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomSelect value={String(bucketPoiYear)} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getIntlLanguage } from '@trek/shared'
|
||||
|
||||
export interface PlaceLocality {
|
||||
/** The country's name in the reader's language, or as the address spells it. */
|
||||
country: string | null
|
||||
@@ -34,7 +36,7 @@ function countryFromAddress(address: string | null | undefined): string | null {
|
||||
export function placeLocality(place: LocatedPlace, language: string): PlaceLocality {
|
||||
let country: string | null = null
|
||||
if (place.country_code) {
|
||||
try { country = new Intl.DisplayNames([language], { type: 'region' }).of(place.country_code.toUpperCase()) ?? null } catch { country = null }
|
||||
try { country = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }).of(place.country_code.toUpperCase()) ?? null } catch { country = null }
|
||||
}
|
||||
return {
|
||||
country: country ?? countryFromAddress(place.address),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getIntlLanguage, SUPPORTED_LANGUAGES } from './languages';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('getIntlLanguage', () => {
|
||||
it('maps the two codes that are not the BCP-47 tag of their language', () => {
|
||||
expect(getIntlLanguage('br')).toBe('pt-BR');
|
||||
expect(getIntlLanguage('gr')).toBe('el');
|
||||
});
|
||||
|
||||
it('passes every other supported code through and falls back to English', () => {
|
||||
expect(getIntlLanguage('de')).toBe('de');
|
||||
expect(getIntlLanguage('zh-TW')).toBe('zh-TW');
|
||||
expect(getIntlLanguage('xx')).toBe('en');
|
||||
});
|
||||
|
||||
it('gives Intl a tag it formats in the right language for every supported code', () => {
|
||||
for (const { value, locale } of SUPPORTED_LANGUAGES) {
|
||||
const primary = locale.split('-')[0];
|
||||
expect(new Intl.Locale(getIntlLanguage(value)).language, value).toBe(primary);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,9 +43,14 @@ export function getLocaleForLanguage(language: string): string {
|
||||
return LOCALES[language] ?? LOCALES['en'] ?? 'en-US';
|
||||
}
|
||||
|
||||
// Returns a BCP-47 tag suitable for Intl APIs.
|
||||
// Two codes TREK uses are not the BCP-47 tag of their language: 'br' is Breton
|
||||
// and 'gr' is no language at all.
|
||||
const INTL_LANGUAGE: Partial<Record<string, string>> = { br: 'pt-BR', gr: 'el' };
|
||||
|
||||
// Returns a BCP-47 tag suitable for Intl APIs and the document's lang attribute.
|
||||
export function getIntlLanguage(language: string): string {
|
||||
if (language === 'br') return 'pt-BR';
|
||||
const tag = INTL_LANGUAGE[language];
|
||||
if (tag) return tag;
|
||||
return SUPPORTED_LANGUAGE_CODES.includes(language) ? language : 'en';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user