Language Specialist
Expert assistant for all i18n/l10n work in the Raamattu Nyt project.
Architecture Overview
packages/shared-i18n/ # Shared i18n package
├── src/
│ ├── config.ts # i18next initialization
│ ├── types.ts # SupportedLanguage, LANGUAGE_INFO
│ ├── provider.tsx # I18nProvider component
│ ├── hooks/useLanguage.ts # Language preference hook
│ └── utils/
│ ├── setLanguage.ts # Simple language switcher
│ └── dateLocale.ts # date-fns locale helper
apps/raamattu-nyt/public/locales/ # Translation files (lazy-loaded)
├── fi/
│ ├── common.json # Shared UI strings
│ └── profile.json # Profile page strings
└── en/
├── common.json
└── profile.json
Quick Reference
Add translation to existing namespace
- Add key to both locale files:
// public/locales/fi/common.json
{ "myKey": "Suomeksi" }
// public/locales/en/common.json
{ "myKey": "In English" }
- Use in component:
import { useTranslation } from '@shared-i18n/index';
const { t } = useTranslation('common');
return <span>{t('myKey')}</span>;
Create new namespace
- Add namespace to
packages/shared-i18n/src/config.ts:
ns: ['common', 'profile', 'newNamespace'],
-
Create files:
public/locales/{fi,en}/newNamespace.json -
Use:
const { t } = useTranslation('newNamespace');
Add new language
- Update
packages/shared-i18n/src/types.ts:
export type SupportedLanguage = 'fi' | 'en' | 'sv';
export const SUPPORTED_LANGUAGES = ['fi', 'en', 'sv'] as const;
export const LANGUAGE_INFO = {
fi: { code: 'fi', name: 'Finnish', nativeName: 'Suomi' },
en: { code: 'en', name: 'English', nativeName: 'English' },
sv: { code: 'sv', name: 'Swedish', nativeName: 'Svenska' },
};
- Update DB constraint in new migration:
ALTER TABLE public.profiles
DROP CONSTRAINT profiles_language_preference_check,
ADD CONSTRAINT profiles_language_preference_check
CHECK (language_preference IN ('fi', 'en', 'sv'));
-
Create locale files:
public/locales/sv/*.json -
Update
dateLocale.tswith new locale import
Translation Patterns
Interpolation
{ "greeting": "Hello, {{name}}!" }
t('greeting', { name: 'John' })
Pluralization
{
"item_one": "{{count}} item",
"item_other": "{{count}} items"
}
t('item', { count: 5 })
Nested keys
{ "nav": { "home": "Home", "settings": "Settings" } }
t('nav.home')
Trans component (JSX interpolation)
import { Trans } from '@shared-i18n/index';
<Trans i18nKey="terms" t={t}>
By continuing you agree to our <Link to="/terms">terms</Link>.
</Trans>
Migration Workflow
When migrating a component to i18n:
- Identify all user-visible strings
- Create translation keys (use descriptive, nested structure)
- Add to appropriate namespace (common for shared, feature-specific otherwise)
- Replace inline strings with
t()calls - Test both languages
Example migration:
// Before
<Button>Tallenna</Button>
// After
const { t } = useTranslation('common');
<Button>{t('common.save')}</Button>
Cross-cutting learnings: See .claude/LEARNINGS.md → "i18n/Translations" section for namespace patterns and useTranslation gotchas.
i18n Gotchas
Empty-string values render the raw key
The shared i18n config has returnEmptyString: false, which means an empty translation "" falls back to the key string itself (e.g. "prayerCard.recurrence.none" shows up in the UI as raw text).
Wrong: Use a t(key) with an empty fallback value to "hide" a label conditionally:
{ "prayerCard.recurrence.none": "" }
<Badge>{t(`prayerCard.recurrence.${recurrence}`)}</Badge>
Result: badge renders the literal key when recurrence === "none".
Right: Skip the t() call when the value would be empty:
const label = recurrence && recurrence !== "none"
? t(`prayerCard.recurrence.${recurrence}`)
: "";
{label && <Badge>{label}</Badge>}
Or remove the empty key entirely and rely on i18next's keyNotFound behavior + a guard.
DB strings already include their prefix — don't add it again from i18n
Some database string fields already include their human-readable prefix (e.g. prayer_calendars.name = "Rukouskalenteri Suomi"). Adding t("prayerRoom.header.calendarPrefix") (= "Rukouskalenteri ") on top yields "Rukouskalenteri Rukouskalenteri Suomi".
Rule: Before adding a translatable prefix in front of a DB-backed name, check the DB value or its source migration. If the prefix is already in the data, omit the i18n prefix at render time. If you need the prefix consistently, normalize it at write time (one source of truth) and document which side owns it.
Affected fields seen so far:
| Table.column | Already includes |
|---|---|
| prayer_calendars.name | "Rukouskalenteri " |
Admin Inline Editing (EditableText)
Any i18n-backed string rendered via t() can be opened for admin inline editing by wrapping it in <EditableText>. Non-admins see the original render with zero footprint; admins get a pencil that opens a FI/EN dialog, and Save commits to the locale JSON via the save-i18n-value Edge Function. No new keys are created — the key must already exist in the locale file.
Import: import { EditableText } from "@/components/inline-edit/EditableText";
Pattern 1 — Plain text (title, paragraph, span)
<EditableText i18nKey="myPage.hero.title">
{t("myPage.hero.title")}
</EditableText>
<EditableText i18nKey="myPage.hero.body" placement="block">
<p>{t("myPage.hero.body")}</p>
</EditableText>
placement="inline"(default) — pencil inline with the text (titles, spans).placement="block"— pencil absolute-positioned top-right (paragraphs, lists, cards).namespacedefaults to"common"; other allowed:cinema,landing,profile,idea-machina. When adding a new namespace, also updateALLOWED_NAMESPACESin the Edge Function.
Pattern 2 — Lists (e.g. Bible refs, link lists)
EditableText edits one string (Textarea). For lists, store items newline-separated in a single i18n key and split in render — not comma-separated (Bible refs can contain commas themselves like Joh. 3:16, 17).
i18n value:
"verses": "Joh. 3:16\nRoom. 10:9–10\nEf. 2:8–9\nHepr. 11:1"
Render:
<EditableText i18nKey="...verses" placement="block">
<ul className="list-disc list-inside space-y-1">
{t("...verses")
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean)
.map((ref) => (
<li key={ref}><BibleReferenceText text={ref} versionCode="finpr_finn" /></li>
))}
</ul>
</EditableText>
Admin types one item per line in the dialog. .filter(Boolean) tolerates blank lines.
Reusable for: bullet lists, link lists, CTA text variants, any n-item list.
When NOT to use EditableText
- Dynamic DB content — edits locale JSON, not Supabase tables. Use a DB-backed content system instead.
- Per-user/per-tenant variants — same reason; locale JSON is global.
- Rich text / Markdown — v1 is plain Textarea only.
- Creating new keys inline — v1 requires the key to exist; returns 400 otherwise.
Full reference
Docs/ai/inline-content-editor.md — architecture diagram, Edge Function flow, GitHub commit details, v1 scope boundaries, v2+ expansion paths.
Files Reference
For detailed API and patterns, see references/i18n-api.md.
Read before touching locale JSON: references/learnings.md — the validator-skips-arrays trap (FAQ/steps must be numeric-keyed objects), inline-fallback = FI source, and other debt traps.