Text Localization #30

Merged
an.di merged 4 commits from adilallo/feature/TextLocalization into main 2026-01-31 01:42:21 +00:00
23 changed files with 139 additions and 47 deletions
Showing only changes of commit ebd025fe27 - Show all commits
@@ -13,32 +13,48 @@ const FeatureGridContainer = memo<FeatureGridProps>(
() => [
{
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
labelLine1: t("pages.home.featureGrid.features.decisionMaking.labelLine1"),
labelLine2: t("pages.home.featureGrid.features.decisionMaking.labelLine2"),
labelLine1: t(
"pages.home.featureGrid.features.decisionMaking.labelLine1",
),
labelLine2: t(
"pages.home.featureGrid.features.decisionMaking.labelLine2",
),
panelContent: "/assets/Feature_Support.png",
ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"),
href: "#decision-making",
},
{
backgroundColor: "bg-[#D1FFE2]",
labelLine1: t("pages.home.featureGrid.features.valuesAlignment.labelLine1"),
labelLine2: t("pages.home.featureGrid.features.valuesAlignment.labelLine2"),
labelLine1: t(
"pages.home.featureGrid.features.valuesAlignment.labelLine1",
),
labelLine2: t(
"pages.home.featureGrid.features.valuesAlignment.labelLine2",
),
panelContent: "/assets/Feature_Exercises.png",
ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"),
href: "#values-alignment",
},
{
backgroundColor: "bg-[#F4CAFF]",
labelLine1: t("pages.home.featureGrid.features.membershipGuidance.labelLine1"),
labelLine2: t("pages.home.featureGrid.features.membershipGuidance.labelLine2"),
labelLine1: t(
"pages.home.featureGrid.features.membershipGuidance.labelLine1",
),
labelLine2: t(
"pages.home.featureGrid.features.membershipGuidance.labelLine2",
),
panelContent: "/assets/Feature_Guidance.png",
ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"),
href: "#membership-guidance",
},
{
backgroundColor: "bg-[#CBDDFF]",
labelLine1: t("pages.home.featureGrid.features.conflictResolution.labelLine1"),
labelLine2: t("pages.home.featureGrid.features.conflictResolution.labelLine2"),
labelLine1: t(
"pages.home.featureGrid.features.conflictResolution.labelLine1",
),
labelLine2: t(
"pages.home.featureGrid.features.conflictResolution.labelLine2",
),
panelContent: "/assets/Feature_Tools.png",
ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"),
href: "#conflict-resolution",
+1 -4
View File
@@ -17,10 +17,7 @@ const Footer = memo(() => {
name: t("organization.name"),
email: t("organization.email"),
url: t("organization.url"),
sameAs: [
t("social.bluesky.url"),
t("social.gitlab.url"),
],
sameAs: [t("social.bluesky.url"), t("social.gitlab.url")],
};
return (
+6 -5
View File
@@ -96,7 +96,11 @@ const HeaderContainer = memo<HeaderProps>(() => {
const renderLoginButton = (size: NavSize) => {
return (
<MenuBarItem href="#" size={size} ariaLabel={t("ariaLabels.logInToAccount")}>
<MenuBarItem
href="#"
size={size}
ariaLabel={t("ariaLabels.logInToAccount")}
>
{t("buttons.logIn")}
</MenuBarItem>
);
@@ -108,10 +112,7 @@ const HeaderContainer = memo<HeaderProps>(() => {
avatarSize: "small" | "medium" | "large" | "xlarge",
) => {
return (
<Button
size={buttonSize}
ariaLabel={t("ariaLabels.createNewRule")}
>
<Button size={buttonSize} ariaLabel={t("ariaLabels.createNewRule")}>
{renderAvatarGroup(containerSize, avatarSize)}
<span>{t("buttons.createRule")}</span>
</Button>
+1 -1
View File
@@ -1,3 +1,3 @@
export { default } from "./Header.container";
export type { HeaderProps } from "./Header.types";
export { navigationItems, avatarImages, logoConfig } from "./Header.container";
export { avatarImages, logoConfig } from "./Header.container";
+4 -1
View File
@@ -12,7 +12,10 @@ interface MessagesProviderProps {
children: ReactNode;
}
export function MessagesProvider({ messages, children }: MessagesProviderProps) {
export function MessagesProvider({
messages,
children,
}: MessagesProviderProps) {
return (
<MessagesContext.Provider value={messages}>
{children}
+1 -5
View File
@@ -87,11 +87,7 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
export default function RootLayout({ children }: { children: ReactNode }) {
// Load messages for the default locale (single locale setup)
return (
+28 -5
View File
@@ -5,6 +5,7 @@ This guide explains how to work with translations in the CommunityRule applicati
## Overview
All UI text is stored in JSON files under `messages/en/`. The structure follows best practices:
- **Page-specific content** lives in `pages/` (varies by page context)
- **Component defaults** live in `components/` (shared across pages)
- **Common strings** live in `common.json` (shared UI elements)
@@ -35,6 +36,7 @@ messages/
## When to Use `pages/` vs `components/`
### Use `pages/` for:
- **Page-specific content**: Titles, subtitles, descriptions that vary by page
- **Context-aware text**: Content that changes based on where the component is used
- **User-facing content**: All text that users see on a specific page
@@ -42,6 +44,7 @@ messages/
**Example:** The home page hero banner title "Collaborate" goes in `pages/home.json`, not `components/heroBanner.json`
### Use `components/` for:
- **Component defaults**: Aria-labels, alt text patterns, shared behavior text
- **Shared across pages**: Text that doesn't vary by page context
- **Accessibility text**: Aria-labels and alt texts that are component-level
@@ -49,6 +52,7 @@ messages/
**Example:** The hero banner image alt text "Hero illustration" stays in `components/heroBanner.json` because it's the same across all pages
### Use `common.json` for:
- **Shared UI strings**: Buttons, links, labels used across multiple components
- **Global strings**: Text that appears in many places
@@ -57,23 +61,25 @@ messages/
For page-specific content, use the `pages.*` namespace pattern:
**Server Components:**
```typescript
import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation";
export default function LearnPage() {
const t = (key: string) => getTranslation(messages, key);
const contentLockupData = {
title: t("pages.learn.contentLockup.title"),
subtitle: t("pages.learn.contentLockup.subtitle"),
};
return <ContentLockup {...contentLockupData} />;
}
```
**Client Components:**
```typescript
"use client";
import { useTranslation } from "../../contexts/MessagesContext";
@@ -95,6 +101,7 @@ Determine which component needs the translation. If it's a shared string (like a
Open the appropriate JSON file and add your translation key. Use descriptive, semantic keys:
**Good:**
```json
{
"heroBanner": {
@@ -105,6 +112,7 @@ Open the appropriate JSON file and add your translation key. Use descriptive, se
```
**Bad:**
```json
{
"text1": "Collaborate",
@@ -131,24 +139,26 @@ Group related translations together:
### 4. Update the Component or Page
**For Page Components (Server Components):**
```typescript
import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation";
export default function MyPage() {
const t = (key: string) => getTranslation(messages, key);
// Use page-specific keys
const data = {
title: t("pages.home.heroBanner.title"),
subtitle: t("pages.home.heroBanner.subtitle"),
};
return <HeroBanner {...data} />;
}
```
**For Client Components:**
```typescript
"use client";
import { useTranslation } from "../../contexts/MessagesContext";
@@ -157,7 +167,7 @@ export default function MyComponent() {
// For page-specific content
const t = useTranslation("pages.home.heroBanner");
return <h1>{t("title")}</h1>;
// For component defaults
const tDefault = useTranslation("heroBanner");
return <img alt={tDefault("imageAlt")} />;
@@ -173,6 +183,7 @@ export default function MyComponent() {
5. **Include context in comments**: Use `_comment` fields for clarity
Example:
```json
{
"_comment": "HeroBanner component translations",
@@ -194,6 +205,7 @@ When migrating a component to use translations:
### Example Migration
**Before:**
```typescript
export default function HeroBanner() {
return (
@@ -206,6 +218,7 @@ export default function HeroBanner() {
```
**After:**
```typescript
"use client";
import { useTranslations } from "next-intl";
@@ -231,6 +244,7 @@ When creating a new page that needs translations:
4. **Use in page component**: Use `t("pages.about.*")` pattern in your page
**Example:**
```typescript
// messages/en/pages/about.json
{
@@ -280,6 +294,7 @@ When adding support for a new language:
## Common Patterns
### Buttons and CTAs
```json
{
"buttons": {
@@ -290,6 +305,7 @@ When adding support for a new language:
```
### Aria Labels
```json
{
"ariaLabels": {
@@ -300,7 +316,9 @@ When adding support for a new language:
```
### Dynamic Content
For content that varies (like card text), use arrays or numbered keys:
```json
{
"cards": {
@@ -316,6 +334,7 @@ For content that varies (like card text), use arrays or numbered keys:
### Translation Key Not Found
If you see a key path like `heroBanner.title` instead of the text:
1. Check the JSON file exists and has the key
2. Verify the key path matches exactly (case-sensitive)
3. Restart the dev server if you just added the key
@@ -323,6 +342,7 @@ If you see a key path like `heroBanner.title` instead of the text:
### TypeScript Errors
If TypeScript complains about translation keys:
1. Ensure the key exists in the JSON file
2. Check for typos in the key path
3. Verify the namespace is correct if using `useTranslations("namespace")`
@@ -330,6 +350,7 @@ If TypeScript complains about translation keys:
### Missing Translations
If text doesn't appear:
1. Check the browser console for errors
2. Verify the component is wrapped in `MessagesProvider` (for client components)
3. Ensure `getTranslation()` is called correctly in server components
@@ -338,10 +359,12 @@ If text doesn't appear:
## Architecture: Hybrid Approach
This implementation follows the recognized best practice of combining:
- **Globalized, shared UI elements**: Component defaults in `components/` (aria-labels, alt texts)
- **Context-aware, localized content pages**: Page-specific content in `pages/` (titles, descriptions)
This allows:
- Components to remain flexible and reusable
- Page content to be easily edited without code changes
- Clear separation between shared defaults and page-specific content
+1 -4
View File
@@ -11,10 +11,7 @@ type Messages = typeof messages;
* @param key - Dot-separated key path (e.g., "heroBanner.title")
* @returns The translation string or the key if not found
*/
export function getTranslation(
messages: Messages,
key: string,
): string {
export function getTranslation(messages: Messages, key: string): string {
const keys = key.split(".");
let value: any = messages;
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Type definitions for translation keys
*
*
* These types provide type safety when accessing translation keys.
* The actual types are inferred from the JSON files in messages/en/
*/
+6 -1
View File
@@ -3,6 +3,11 @@
"home": {
"title": "CommunityRule - Build operating manuals for successful communities",
"description": "Help your community make important decisions in a way that reflects its unique values.",
"keywords": ["community", "governance", "decision-making", "operating manual"]
"keywords": [
"community",
"governance",
"decision-making",
"operating manual"
]
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest";
import AskOrganizer from "../../app/components/AskOrganizer";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest";
import FeatureGrid from "../../app/components/FeatureGrid";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest";
import Footer from "../../app/components/Footer";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest";
import HeroBanner from "../../app/components/HeroBanner";
import {
+5 -1
View File
@@ -1,5 +1,9 @@
import { describe, test, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import {
renderWithProviders as render,
screen,
waitFor,
} from "../utils/test-utils";
import Page from "../../app/page";
describe("Page", () => {
+6 -1
View File
@@ -1,4 +1,9 @@
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import {
renderWithProviders as render,
screen,
cleanup,
waitFor,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { vi, describe, test, expect, afterEach } from "vitest";
import React from "react";
+6 -1
View File
@@ -1,4 +1,9 @@
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import {
renderWithProviders as render,
screen,
cleanup,
waitFor,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { vi, describe, test, expect, afterEach } from "vitest";
import React from "react";
+5 -1
View File
@@ -1,4 +1,8 @@
import { render, screen, cleanup } from "@testing-library/react";
import {
renderWithProviders as render,
screen,
cleanup,
} from "../utils/test-utils";
import { describe, test, expect, afterEach } from "vitest";
import NumberedCards from "../../app/components/NumberedCards";
+5 -1
View File
@@ -1,4 +1,8 @@
import { render, screen, cleanup } from "@testing-library/react";
import {
renderWithProviders as render,
screen,
cleanup,
} from "../utils/test-utils";
import { vi, describe, test, expect, afterEach } from "vitest";
import QuoteBlock from "../../app/components/QuoteBlock";
+5 -1
View File
@@ -1,4 +1,8 @@
import { render, screen, fireEvent } from "@testing-library/react";
import {
renderWithProviders as render,
screen,
fireEvent,
} from "../utils/test-utils";
import { describe, it, expect, vi } from "vitest";
import RuleCard from "../../app/components/RuleCard";
+5 -1
View File
@@ -1,4 +1,8 @@
import { render, screen, cleanup } from "@testing-library/react";
import {
renderWithProviders as render,
screen,
cleanup,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { vi, describe, test, expect, afterEach } from "vitest";
import { logger } from "../../lib/logger";
+2 -1
View File
@@ -1,8 +1,9 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { renderWithProviders as render } from "./test-utils";
type TestCases = {
renders?: boolean;
+23
View File
@@ -0,0 +1,23 @@
import React, { type ReactElement } from "react";
import { render, type RenderOptions } from "@testing-library/react";
import { MessagesProvider } from "../../app/contexts/MessagesContext";
import messages from "../../messages/en/index";
/**
* Custom render function that wraps components with MessagesProvider
* Use this instead of the default render from @testing-library/react
* for components that use useTranslation hook
*/
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">,
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return <MessagesProvider messages={messages}>{children}</MessagesProvider>;
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Re-export everything from @testing-library/react for convenience
export * from "@testing-library/react";