Localization with pages context

This commit is contained in:
adilallo
2026-01-30 18:03:50 -07:00
parent 14ec2dd2a0
commit 1280844706
13 changed files with 283 additions and 134 deletions
@@ -13,32 +13,32 @@ const FeatureGridContainer = memo<FeatureGridProps>(
() => [
{
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
labelLine1: t("featureGrid.features.decisionMaking.labelLine1"),
labelLine2: t("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("featureGrid.features.valuesAlignment.labelLine1"),
labelLine2: t("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("featureGrid.features.membershipGuidance.labelLine1"),
labelLine2: t("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("featureGrid.features.conflictResolution.labelLine1"),
labelLine2: t("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 -1
View File
@@ -11,7 +11,7 @@ export function RuleStackView({
className,
onTemplateClick,
}: RuleStackViewProps) {
const t = useTranslation("ruleStack");
const t = useTranslation("pages.home.ruleStack");
return (
<section
+12 -9
View File
@@ -1,3 +1,5 @@
import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation";
import ContentThumbnailTemplate from "../components/ContentThumbnailTemplate";
import ContentLockup from "../components/ContentLockup";
import AskOrganizer from "../components/AskOrganizer";
@@ -7,21 +9,22 @@ export default function LearnPage() {
// Get real blog posts from the content system
const allPosts = getAllBlogPosts();
// Use direct message access for server components
const t = (key: string) => getTranslation(messages, key);
const contentLockupData = {
title: "Organizing is hard",
subtitle:
"Find answers to your questions and see how other groups have solved similar challenges.",
title: t("pages.learn.contentLockup.title"),
subtitle: t("pages.learn.contentLockup.subtitle"),
variant: "learn" as const,
alignment: "left" as const,
};
const askOrganizerData = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
description:
"Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.",
buttonText: "Ask an organizer",
buttonHref: "/contact",
title: t("pages.learn.askOrganizer.title"),
subtitle: t("pages.learn.askOrganizer.subtitle"),
description: t("pages.learn.askOrganizer.description"),
buttonText: t("pages.learn.askOrganizer.buttonText"),
buttonHref: t("pages.learn.askOrganizer.buttonHref"),
variant: "centered" as const,
};
+16 -16
View File
@@ -46,29 +46,29 @@ export default function Page() {
const t = (key: string) => getTranslation(messages, key);
const heroBannerData = {
title: t("heroBanner.title"),
subtitle: t("heroBanner.subtitle"),
description: t("heroBanner.description"),
ctaText: t("heroBanner.ctaText"),
ctaHref: t("heroBanner.ctaHref"),
title: t("pages.home.heroBanner.title"),
subtitle: t("pages.home.heroBanner.subtitle"),
description: t("pages.home.heroBanner.description"),
ctaText: t("pages.home.heroBanner.ctaText"),
ctaHref: t("pages.home.heroBanner.ctaHref"),
};
const numberedCardsData = {
title: t("numberedCards.title"),
subtitle: t("numberedCards.subtitle"),
title: t("pages.home.numberedCards.title"),
subtitle: t("pages.home.numberedCards.subtitle"),
cards: [
{
text: t("numberedCards.cards.card1.text"),
text: t("pages.home.numberedCards.cards.card1.text"),
iconShape: "blob",
iconColor: "green",
},
{
text: t("numberedCards.cards.card2.text"),
text: t("pages.home.numberedCards.cards.card2.text"),
iconShape: "gear",
iconColor: "purple",
},
{
text: t("numberedCards.cards.card3.text"),
text: t("pages.home.numberedCards.cards.card3.text"),
iconShape: "star",
iconColor: "orange",
},
@@ -76,15 +76,15 @@ export default function Page() {
};
const featureGridData = {
title: t("featureGrid.title"),
subtitle: t("featureGrid.subtitle"),
title: t("pages.home.featureGrid.title"),
subtitle: t("pages.home.featureGrid.subtitle"),
};
const askOrganizerData = {
title: t("askOrganizer.title"),
subtitle: t("askOrganizer.subtitle"),
buttonText: t("askOrganizer.buttonText"),
buttonHref: t("askOrganizer.buttonHref"),
title: t("pages.home.askOrganizer.title"),
subtitle: t("pages.home.askOrganizer.subtitle"),
buttonText: t("pages.home.askOrganizer.buttonText"),
buttonHref: t("pages.home.askOrganizer.buttonHref"),
};
return (
+136 -34
View File
@@ -1,28 +1,89 @@
# i18n Translation Workflow Guide
This guide explains how to work with translations in the CommunityRule application. The app uses `next-intl` for managing UI text content, making it easy for content creators and contributors to update text without modifying component code.
This guide explains how to work with translations in the CommunityRule application. The app uses a hybrid approach combining globalized, shared UI elements with context-aware, localized content pages, making it easy for content creators and contributors to update text without modifying component code.
## Overview
All UI text is stored in JSON files under `messages/en/`. Components reference these translations using keys, allowing content to be edited independently of the codebase.
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)
Components reference these translations using keys, allowing content to be edited independently of the codebase.
## Directory Structure
```
messages/
en/
common.json # Shared UI strings (buttons, links, labels)
pages/
home.json # Home page specific content
learn.json # Learn page specific content
components/
heroBanner.json # HeroBanner component translations
numberedCards.json # NumberedCards component translations
askOrganizer.json # AskOrganizer component translations
featureGrid.json # FeatureGrid component translations
footer.json # Footer component translations
heroBanner.json # Component defaults (aria-labels, alt texts)
numberedCards.json # Component defaults
askOrganizer.json # Component defaults
featureGrid.json # Component defaults
footer.json # Shared across pages
header.json # Shared across pages
common.json # Shared UI strings (buttons, links, labels)
navigation.json # Navigation items
metadata.json # Page metadata (title, description)
index.ts # Exports all 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
**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
**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
## Page-Specific Translations
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";
export default function MyComponent() {
const t = useTranslation("pages.home.heroBanner");
return <h1>{t("title")}</h1>;
}
```
## Adding New Translation Keys
### 1. Identify the Component
@@ -67,35 +128,40 @@ Group related translations together:
}
```
### 4. Update the Component
### 4. Update the Component or Page
In your component, use the translation hook:
**Server Components:**
**For Page Components (Server Components):**
```typescript
import { getTranslations } from "next-intl/server";
import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation";
export default async function MyComponent() {
const t = await getTranslations();
return <h1>{t("heroBanner.title")}</h1>;
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} />;
}
```
**Client Components:**
**For Client Components:**
```typescript
"use client";
import { useTranslations } from "next-intl";
import { useTranslation } from "../../contexts/MessagesContext";
export default function MyComponent() {
const t = useTranslations();
return <h1>{t("heroBanner.title")}</h1>;
}
```
// For page-specific content
const t = useTranslation("pages.home.heroBanner");
return <h1>{t("title")}</h1>;
**Namespace-specific (recommended for component files):**
```typescript
const t = useTranslations("heroBanner");
return <h1>{t("title")}</h1>;
// For component defaults
const tDefault = useTranslation("heroBanner");
return <img alt={tDefault("imageAlt")} />;
}
```
## Translation Key Naming Conventions
@@ -155,15 +221,38 @@ export default function HeroBanner() {
}
```
## Adding a New Page
When creating a new page that needs translations:
1. **Create a page translation file**: `messages/en/pages/about.json` (for example)
2. **Add page-specific content**: All user-facing text for that page
3. **Import in index.ts**: Add the import and export in `messages/en/index.ts`
4. **Use in page component**: Use `t("pages.about.*")` pattern in your page
**Example:**
```typescript
// messages/en/pages/about.json
{
"hero": {
"title": "About Us",
"subtitle": "Learn more about our mission"
}
}
// app/about/page.tsx
const t = (key: string) => getTranslation(messages, key);
const title = t("pages.about.hero.title");
```
## Adding New Languages (Future)
When adding support for a new language:
1. **Create a new locale directory**: `messages/es/` (for Spanish, for example)
2. **Copy the English files** as a starting point
2. **Copy the English files** as a starting point (including `pages/` structure)
3. **Translate all strings** in the JSON files
4. **Update `app/i18n/routing.ts`** to include the new locale
5. **Test thoroughly** to ensure all translations are present
4. **Test thoroughly** to ensure all translations are present
## Testing Translations
@@ -242,14 +331,27 @@ If TypeScript complains about translation keys:
If text doesn't appear:
1. Check the browser console for errors
2. Verify the component is wrapped in `NextIntlClientProvider` (for client components)
3. Ensure `getMessages()` is called in server components
2. Verify the component is wrapped in `MessagesProvider` (for client components)
3. Ensure `getTranslation()` is called correctly in server components
4. Check if you're using the correct namespace (`pages.*` vs component defaults)
## 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
- Scalable structure for adding new pages
## Resources
- [next-intl Documentation](https://next-intl.dev/docs)
- [Next.js Internationalization](https://nextjs.org/docs/app/guides/internationalization)
- Component-specific translation files in `messages/en/components/`
- Component defaults in `messages/en/components/`
- Page-specific content in `messages/en/pages/`
- Shared UI strings in `messages/en/common.json`
---
+1 -5
View File
@@ -1,8 +1,4 @@
{
"_comment": "AskOrganizer component translations",
"title": "Still have questions?",
"subtitle": "Get answers from an experienced organizer",
"buttonText": "Ask an organizer",
"buttonHref": "#contact",
"_comment": "AskOrganizer component defaults (shared across pages)",
"ariaLabel": "Ask an organizer - Contact an organizer for help"
}
+1 -11
View File
@@ -1,29 +1,19 @@
{
"_comment": "FeatureGrid component translations",
"title": "We've got your back, every step of the way",
"subtitle": "Use our toolkit to improve, document, and evolve your organization.",
"_comment": "FeatureGrid component defaults (shared across pages)",
"linkText": "Learn more",
"linkHref": "#",
"ariaLabel": "Feature tools and services",
"features": {
"decisionMaking": {
"labelLine1": "Decision-making",
"labelLine2": "support",
"ariaLabel": "Decision-making support tools"
},
"valuesAlignment": {
"labelLine1": "Values alignment",
"labelLine2": "exercises",
"ariaLabel": "Values alignment exercises"
},
"membershipGuidance": {
"labelLine1": "Membership",
"labelLine2": "guidance",
"ariaLabel": "Membership guidance resources"
},
"conflictResolution": {
"labelLine1": "Conflict resolution",
"labelLine2": "tools",
"ariaLabel": "Conflict resolution tools"
}
}
+1 -6
View File
@@ -1,9 +1,4 @@
{
"_comment": "HeroBanner component translations",
"title": "Collaborate",
"subtitle": "with clarity",
"description": "Help your community make important decisions in a way that reflects its unique values.",
"ctaText": "Learn how CommunityRule works",
"ctaHref": "#",
"_comment": "HeroBanner component defaults (shared across pages)",
"imageAlt": "Hero illustration"
}
+1 -17
View File
@@ -1,24 +1,8 @@
{
"_comment": "NumberedCards component translations",
"title": "How CommunityRule works",
"subtitle": "Here's a quick overview of the process, from start to finish.",
"_comment": "NumberedCards component defaults (shared across pages)",
"titleLg": "How CommunityRule helps",
"buttons": {
"createCommunityRule": "Create CommunityRule",
"seeHowItWorks": "See how it works"
},
"cards": {
"card1": {
"text": "Document how your community makes decisions",
"_comment": "First step card"
},
"card2": {
"text": "Build an operating manual for a successful community",
"_comment": "Second step card"
},
"card3": {
"text": "Get a link to your manual for your group to review and evolve",
"_comment": "Third step card"
}
}
}
+1 -25
View File
@@ -1,28 +1,4 @@
{
"cards": {
"consensusClusters": {
"title": "Consensus clusters",
"description": "Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.",
"iconAlt": "Sociocracy"
},
"consensus": {
"title": "Consensus",
"description": "Decisions that affect the group collectively should involve participation of all participants.",
"iconAlt": "Consensus"
},
"electedBoard": {
"title": "Elected Board",
"description": "An elected board determines policies and organizes their implementation.",
"iconAlt": "Elected Board"
},
"petition": {
"title": "Petition",
"description": "All participants can propose and vote on proposals for the group.",
"iconAlt": "Petition"
}
},
"button": {
"seeAllTemplates": "See all templates"
},
"_comment": "RuleStack component defaults (shared across pages)",
"ariaLabel": "Learn more about {title} governance pattern"
}
+6
View File
@@ -11,6 +11,8 @@ import menuBar from "./components/menuBar.json";
import quoteBlock from "./components/quoteBlock.json";
import ruleCard from "./components/ruleCard.json";
import ruleStack from "./components/ruleStack.json";
import home from "./pages/home.json";
import learn from "./pages/learn.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
@@ -28,6 +30,10 @@ export default {
quoteBlock,
ruleCard,
ruleStack,
pages: {
home,
learn,
},
navigation,
metadata,
};
+83
View File
@@ -0,0 +1,83 @@
{
"_comment": "Home page specific content",
"heroBanner": {
"title": "Collaborate",
"subtitle": "with clarity",
"description": "Help your community make important decisions in a way that reflects its unique values.",
"ctaText": "Learn how CommunityRule works",
"ctaHref": "#"
},
"numberedCards": {
"title": "How CommunityRule works",
"subtitle": "Here's a quick overview of the process, from start to finish.",
"cards": {
"card1": {
"text": "Document how your community makes decisions",
"_comment": "First step card"
},
"card2": {
"text": "Build an operating manual for a successful community",
"_comment": "Second step card"
},
"card3": {
"text": "Get a link to your manual for your group to review and evolve",
"_comment": "Third step card"
}
}
},
"featureGrid": {
"title": "We've got your back, every step of the way",
"subtitle": "Use our toolkit to improve, document, and evolve your organization.",
"features": {
"decisionMaking": {
"labelLine1": "Decision-making",
"labelLine2": "support"
},
"valuesAlignment": {
"labelLine1": "Values alignment",
"labelLine2": "exercises"
},
"membershipGuidance": {
"labelLine1": "Membership",
"labelLine2": "guidance"
},
"conflictResolution": {
"labelLine1": "Conflict resolution",
"labelLine2": "tools"
}
}
},
"askOrganizer": {
"title": "Still have questions?",
"subtitle": "Get answers from an experienced organizer",
"buttonText": "Ask an organizer",
"buttonHref": "#contact"
},
"ruleStack": {
"cards": {
"consensusClusters": {
"title": "Consensus clusters",
"description": "Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.",
"iconAlt": "Sociocracy"
},
"consensus": {
"title": "Consensus",
"description": "Decisions that affect the group collectively should involve participation of all participants.",
"iconAlt": "Consensus"
},
"electedBoard": {
"title": "Elected Board",
"description": "An elected board determines policies and organizes their implementation.",
"iconAlt": "Elected Board"
},
"petition": {
"title": "Petition",
"description": "All participants can propose and vote on proposals for the group.",
"iconAlt": "Petition"
}
},
"button": {
"seeAllTemplates": "See all templates"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"_comment": "Learn page specific content",
"contentLockup": {
"title": "Organizing is hard",
"subtitle": "Find answers to your questions and see how other groups have solved similar challenges."
},
"askOrganizer": {
"title": "Still have questions?",
"subtitle": "Get answers from an experienced organizer",
"description": "Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.",
"buttonText": "Ask an organizer",
"buttonHref": "/contact"
}
}