Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
<CopyableField
label={t('earn.fidelity_bond.create_fidelity_bond.label_address')}
value={address}
copiedMessage={t('receive.text_copy_address')}
copiedMessage={t('global.text_address_copied')}
>
<Address value={address} className="animate-in blur-in-10 duration-800" copyable={false} />
</CopyableField>
Expand Down
2 changes: 1 addition & 1 deletion src/components/earn/MoveToJarDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
<CopyableField
label={t('earn.fidelity_bond.move.label_destination')}
value={destinationAddress}
copiedMessage={t('receive.text_copy_address')}
copiedMessage={t('global.text_address_copied')}
>
<Address value={destinationAddress} className="animate-in blur-in-10 duration-800" copyable={false} />
</CopyableField>
Expand Down
2 changes: 1 addition & 1 deletion src/components/earn/RenewBondDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
<CopyableField
label={t('earn.fidelity_bond.create_fidelity_bond.label_address')}
value={destinationAddress}
copiedMessage={t('receive.text_copy_address')}
copiedMessage={t('global.text_address_copied')}
>
<Address value={destinationAddress} className="animate-in blur-in-10 duration-800" copyable={false} />
</CopyableField>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ export function JarBadge({ jarIndex, name }: { jarIndex: JarIndex | undefined; n
{name} <span>#{jarIndex}</span>
</>
) : (
/* TODO: i18n - move key to different namespace */
t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: jarIndex })
t('global.label_jar_n', { jar: jarIndex })
)}
</Badge>
)
Expand Down
2 changes: 1 addition & 1 deletion src/components/send/SendPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ describe('SendPage', () => {
render(<SendPage walletFileName="wallet.jmdat" />)

await waitFor(() =>
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('send.error_loading_min_makers_failed')),
expect(mocks.toastError).toHaveBeenCalledWith('send.error_loading_min_makers_failed:{"reason":"config boom"}'),
)
})

Expand Down
3 changes: 1 addition & 2 deletions src/components/send/SendPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
.catch((error: unknown) => {
if (abortCtrl.signal.aborted) return
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
// TODO: i18n, add reason as param
toast.error(`${t('send.error_loading_min_makers_failed')} ${reason}`)
toast.error(t('send.error_loading_min_makers_failed', { reason }))
})

return () => abortCtrl.abort()
Expand Down
4 changes: 2 additions & 2 deletions src/components/ui/jam/Address.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ const CopyableAddress = ({ className, ...props }: CopyableAddressProps) => {
text={<CopyIcon />}
successText={<CheckIcon className="text-brand-success" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon-xs' }), 'shrink-0')}
onSuccess={() => toast.success(t(/*TODO: i18n - distinct key */ 'receive.text_copy_address'))}
onError={() => toast.error(/*TODO: i18n - distinct key */ t('receive.error_copy_address_failed'))}
onSuccess={() => toast.success(t('global.text_address_copied'))}
onError={() => toast.error(t('global.error_copy_address_failed'))}
/>
</div>
)
Expand Down
6 changes: 5 additions & 1 deletion src/components/ui/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,12 @@ describe('sidebar components', () => {
expect(screen.getByText('Dashboard tooltip')).toBeInTheDocument()
expect(screen.getByLabelText('Search menu')).toBeInTheDocument()

await user.click(screen.getAllByRole('button', { name: 'sidebar.button_toggle' })[0])
const trigger = screen.getAllByRole('button', { name: 'sidebar.button_toggle' })[0]
expect(trigger).toHaveAttribute('title', 'sidebar.trigger.title_close')

await user.click(trigger)
expect(sidebar).toHaveAttribute('data-state', 'collapsed')
expect(trigger).toHaveAttribute('title', 'sidebar.trigger.title_open')
expect(document.cookie).toContain('sidebar_state=false')

await user.keyboard('{Control>}b{/Control}')
Expand Down
6 changes: 1 addition & 5 deletions src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,7 @@ function SidebarTrigger({
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
title={
isSidebarOpen
? /*TODO: t('sidebar.trigger.title_close')*/ t('global.close')
: /*TODO: t('sidebar.trigger.title_open')*/ undefined
}
title={isSidebarOpen ? t('sidebar.trigger.title_close') : t('sidebar.trigger.title_open')}
className={className}
onClick={(event) => {
onClick?.(event)
Expand Down
10 changes: 5 additions & 5 deletions src/components/wallet/BranchEntryTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe('BranchEntryTable', () => {
expect(screen.getByText('deposit')).toBeInTheDocument()
expect(onChange).toHaveBeenCalled()

await user.click(screen.getByText('jar_details.utxo_list.column_title_balance'))
await user.click(screen.getByText('jar_details.address_list.column_title_balance'))

expect(screen.getByText('9000')).toBeInTheDocument()
})
Expand All @@ -94,10 +94,10 @@ describe('BranchEntryTable', () => {
const user = userEvent.setup()
render(<BranchEntryTable tableEntries={[firstEntry, secondEntry]} selectedEntries={[]} pinnedEntries={[]} />)

await user.click(screen.getByText('jar_details.utxo_list.column_title_address'))
await user.click(screen.getByText('jar_details.address_list.column_title_address'))
expect(screen.getByText('bc1qbranch-a')).toBeInTheDocument()

const indexHeader = screen.getByText('jar_details.utxo_list.column_title_index')
const indexHeader = screen.getByText('jar_details.address_list.column_title_index')
expect(indexHeader).toBeInTheDocument()
await user.click(indexHeader)
expect(screen.getByText('bc1qbranch-b')).toBeInTheDocument()
Expand All @@ -111,9 +111,9 @@ describe('BranchEntryTable', () => {
render(<BranchEntryTable tableEntries={[tieB, tieA]} selectedEntries={[]} pinnedEntries={[]} />)

// balance column: equal balances fall back to derivationIndex
await user.click(screen.getByText('jar_details.utxo_list.column_title_balance'))
await user.click(screen.getByText('jar_details.address_list.column_title_balance'))
// address column: equal addresses fall back to derivationIndex
await user.click(screen.getByText('jar_details.utxo_list.column_title_address'))
await user.click(screen.getByText('jar_details.address_list.column_title_address'))

expect(screen.getAllByText('bc1qsame')).toHaveLength(2)
})
Expand Down
16 changes: 4 additions & 12 deletions src/components/wallet/BranchEntryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const BranchEntryTable = ({
() =>
columnHelper.columns([
columnHelper.accessor('derivationIndex', {
header: () => <div className="flex items-center">{t('jar_details.utxo_list.column_title_index')}</div>,
header: () => <div className="flex items-center">{t('jar_details.address_list.column_title_index')}</div>,
sortFn: (a, b) => {
return a.original.derivationIndex - b.original.derivationIndex
},
Expand All @@ -75,11 +75,7 @@ export const BranchEntryTable = ({
},
}),
columnHelper.accessor('address', {
header: () => (
<div className="flex items-center">
{t(/* TODO: i18n keys */ 'jar_details.utxo_list.column_title_address')}
</div>
),
header: () => <div className="flex items-center">{t('jar_details.address_list.column_title_address')}</div>,
sortFn: (a, b) => {
const val = a.original.address.localeCompare(b.original.address)
if (val !== 0) return val
Expand All @@ -92,11 +88,7 @@ export const BranchEntryTable = ({
},
}),
columnHelper.accessor('balance', {
header: () => (
<div className="flex items-center">
{t(/* TODO: i18n keys */ 'jar_details.utxo_list.column_title_balance')}
</div>
),
header: () => <div className="flex items-center">{t('jar_details.address_list.column_title_balance')}</div>,
sortFn: (a, b) => {
const val = a.original.balance - b.original.balance
if (val !== 0) return val
Expand All @@ -110,7 +102,7 @@ export const BranchEntryTable = ({
},
}),
columnHelper.accessor('tags', {
header: () => t(/* TODO: i18n keys */ 'jar_details.utxo_list.column_title_label_and_status'),
header: () => t('jar_details.address_list.column_title_tags'),
cell: (info) => (
<div className="flex items-center gap-2">
{info.row.original.tags.map((it, index) => {
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ describe('i18n config', () => {
expect(document.documentElement.lang).toBe('zh-Hans')
})

it('does not html-escape interpolated values', async () => {
await i18n.changeLanguage('en')
const reason = `Can't reach "http://localhost/api" <timeout> & retry`

expect(i18n.t('send.error_loading_min_makers_failed', { reason })).toBe(
`Loading config value 'minimum_makers' failed. Reason: ${reason}`,
)
expect(i18n.t('logs.text_search_matches', { count: 0, searchValue: 'a/b' })).toBe('No matches for "a/b".')
})

it('keeps regional variants of a shipped language', async () => {
await i18n.changeLanguage('de-AT')
expect(i18n.t('global.cancel')).toBe('Abbrechen')
Expand Down
16 changes: 12 additions & 4 deletions src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,18 @@ const resources = languages.reduce((acc, lng) => {
// Registered before `init` so the initially detected language is applied as well.
i18n.on('languageChanged', (lng) => (document.documentElement.lang = lng))

void i18n.use(LanguageDetector).use(initReactI18next).init({
resources,
fallbackLng: 'en',
})
void i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
interpolation: {
// React escapes rendered text already. Escaping here again would show
// entities like `&#x2F;` or `&#39;` in place of interpolated values.
escapeValue: false,
},
})

// eslint-disable-next-line unicorn/prefer-export-from -- enables nicer syntax
export default i18n
122 changes: 122 additions & 0 deletions src/i18n/locales.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import languages from './languages'

type TranslationTree = { [key: string]: string | TranslationTree }

const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/
const NESTED_KEY = /\$t\(([\w.^-]+)/g
const INTERPOLATION = /\{\{\s*([\w.]+)[^}]*\}\}/g
const COMPONENT = /<\/?\d+\s*\/?>/g

const flatten = (tree: TranslationTree, prefix = '', result: Record<string, string> = {}) => {
for (const [key, value] of Object.entries(tree)) {
const path = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
result[path] = value
} else {
flatten(value, path, result)
}
}
return result
}

const withoutPluralSuffix = (key: string) => key.replace(PLURAL_SUFFIX, '')

const variablesOf = (value: string) => [...value.matchAll(INTERPOLATION)].map((match) => match[1])

// `<Trans>` component placeholders like `<0>`, `</0>` or `<1/>`, in a comparable order
const componentsOf = (value: string) =>
[...value.matchAll(COMPONENT)]
.map((match) => match[0].replaceAll(/\s/g, ''))
.toSorted()
.join(' ')

const en = flatten(languages.find((it) => it.key === 'en')!.translation)
const enBaseKeys = new Set(Object.keys(en).map((key) => withoutPluralSuffix(key)))

// all interpolation variables per (plural-less) key, as languages differ in their plural forms
const enVariables = Object.entries(en).reduce<Record<string, Set<string>>>((acc, [key, value]) => {
const baseKey = withoutPluralSuffix(key)
acc[baseKey] ??= new Set(['count'])
variablesOf(value).forEach((it) => acc[baseKey].add(it))
return acc
}, {})

// the source string a translated key is compared to; plural forms en does not have are compared to `_other`
const sourceOf = (key: string): string | undefined =>
en[key] ?? (PLURAL_SUFFIX.test(key) ? en[`${withoutPluralSuffix(key)}_other`] : undefined)

const isPluralOf = (key: string, baseKey: string) => PLURAL_SUFFIX.test(key) && withoutPluralSuffix(key) === baseKey

// plural keys en itself translates in more than one form (keys with only `_other` are not used as plurals)
const enPluralBaseKeys = Object.keys(en)
.filter((key) => PLURAL_SUFFIX.test(key))
.map((key) => withoutPluralSuffix(key))
.filter((baseKey, index, all) => all.indexOf(baseKey) !== index)

const translated = languages
.filter((it) => it.key !== 'en')
.map((it) => [it.key, flatten(it.translation as TranslationTree)] as const)

describe('translations', () => {
it.each([['en', en] as const, ...translated])('%s: nested $t() references resolve', (_, translation) => {
const unresolved = Object.values(translation)
.flatMap((value) => [...value.matchAll(NESTED_KEY)].map((match) => match[1]))
.filter((key) => !enBaseKeys.has(key))

expect(unresolved).toEqual([])
})

it.each(translated)('%s: contains no keys missing in the source language', (_, translation) => {
const orphans = Object.keys(translation).filter((key) => !enBaseKeys.has(withoutPluralSuffix(key)))

expect(orphans).toEqual([])
})

it.each(translated)('%s: uses only interpolation variables known to the source language', (_, translation) => {
const unknown = Object.entries(translation).flatMap(([key, value]) => {
const known = enVariables[withoutPluralSuffix(key)] ?? new Set<string>()
return variablesOf(value)
.filter((it) => !known.has(it))
.map((it) => `${key}: {{ ${it} }}`)
})

expect(unknown).toEqual([])
})

it.each(translated)('%s: keeps all interpolation variables of the source language', (_, translation) => {
const dropped = Object.entries(translation).flatMap(([key, value]) => {
const source = sourceOf(key)
if (source === undefined) return []
const present = new Set(variablesOf(value))
// `count` is optional, e.g. "one entry" instead of "{{ count }} entry"
return variablesOf(source)
.filter((it) => it !== 'count' && !present.has(it))
.map((it) => `${key}: {{ ${it} }}`)
})

expect(dropped).toEqual([])
})

it.each(translated)('%s: uses the same components as the source language', (_, translation) => {
const mismatched = Object.entries(translation)
.filter(([key, value]) => {
const source = sourceOf(key)
return source !== undefined && componentsOf(source) !== componentsOf(value)
})
.map(([key]) => key)

expect(mismatched).toEqual([])
})

it.each(translated)('%s: contains every plural form the language needs', (lng, translation) => {
const categories = new Intl.PluralRules(lng).resolvedOptions().pluralCategories
const missing = [...new Set(enPluralBaseKeys)]
.filter((baseKey) => Object.keys(translation).some((key) => isPluralOf(key, baseKey)))
.flatMap((baseKey) =>
categories.filter((it) => translation[`${baseKey}_${it}`] === undefined).map((it) => `${baseKey}_${it}`),
)

expect(missing).toEqual([])
})
})
Loading
Loading