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.
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.