diff --git a/app/components/FeatureGrid/FeatureGrid.container.tsx b/app/components/FeatureGrid/FeatureGrid.container.tsx index 996e1e5..967ad3e 100644 --- a/app/components/FeatureGrid/FeatureGrid.container.tsx +++ b/app/components/FeatureGrid/FeatureGrid.container.tsx @@ -13,32 +13,48 @@ const FeatureGridContainer = memo( () => [ { 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", diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 5991759..b6f9e37 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -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 ( diff --git a/app/components/Header/Header.container.tsx b/app/components/Header/Header.container.tsx index 6b459ec..52f6990 100644 --- a/app/components/Header/Header.container.tsx +++ b/app/components/Header/Header.container.tsx @@ -96,7 +96,11 @@ const HeaderContainer = memo(() => { const renderLoginButton = (size: NavSize) => { return ( - + {t("buttons.logIn")} ); @@ -108,10 +112,7 @@ const HeaderContainer = memo(() => { avatarSize: "small" | "medium" | "large" | "xlarge", ) => { return ( - diff --git a/app/components/Header/index.tsx b/app/components/Header/index.tsx index 007f726..71dbfdb 100644 --- a/app/components/Header/index.tsx +++ b/app/components/Header/index.tsx @@ -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"; diff --git a/app/contexts/MessagesContext.tsx b/app/contexts/MessagesContext.tsx index 446175b..92e4fc7 100644 --- a/app/contexts/MessagesContext.tsx +++ b/app/contexts/MessagesContext.tsx @@ -12,7 +12,10 @@ interface MessagesProviderProps { children: ReactNode; } -export function MessagesProvider({ messages, children }: MessagesProviderProps) { +export function MessagesProvider({ + messages, + children, +}: MessagesProviderProps) { return ( {children} diff --git a/app/layout.tsx b/app/layout.tsx index 22bc89d..4b65e98 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( diff --git a/docs/guides/i18n-translation-workflow.md b/docs/guides/i18n-translation-workflow.md index b040626..d63139c 100644 --- a/docs/guides/i18n-translation-workflow.md +++ b/docs/guides/i18n-translation-workflow.md @@ -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 ; } ``` **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 ; } ``` **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

{t("title")}

; - + // For component defaults const tDefault = useTranslation("heroBanner"); return {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 diff --git a/lib/i18n/getTranslation.ts b/lib/i18n/getTranslation.ts index 9baab9d..b737704 100644 --- a/lib/i18n/getTranslation.ts +++ b/lib/i18n/getTranslation.ts @@ -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; diff --git a/lib/i18n/types.ts b/lib/i18n/types.ts index 208f45d..e744fa9 100644 --- a/lib/i18n/types.ts +++ b/lib/i18n/types.ts @@ -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/ */ diff --git a/messages/en/metadata.json b/messages/en/metadata.json index 2df84aa..df71031 100644 --- a/messages/en/metadata.json +++ b/messages/en/metadata.json @@ -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" + ] } } diff --git a/tests/components/AskOrganizer.test.tsx b/tests/components/AskOrganizer.test.tsx index fc73c70..6bfc7be 100644 --- a/tests/components/AskOrganizer.test.tsx +++ b/tests/components/AskOrganizer.test.tsx @@ -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 { diff --git a/tests/components/FeatureGrid.test.tsx b/tests/components/FeatureGrid.test.tsx index dc6b885..4114a68 100644 --- a/tests/components/FeatureGrid.test.tsx +++ b/tests/components/FeatureGrid.test.tsx @@ -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 { diff --git a/tests/components/Footer.test.tsx b/tests/components/Footer.test.tsx index a82ad84..a560672 100644 --- a/tests/components/Footer.test.tsx +++ b/tests/components/Footer.test.tsx @@ -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 { diff --git a/tests/components/HeroBanner.test.tsx b/tests/components/HeroBanner.test.tsx index 4c86006..ac25da3 100644 --- a/tests/components/HeroBanner.test.tsx +++ b/tests/components/HeroBanner.test.tsx @@ -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 { diff --git a/tests/pages/home.test.jsx b/tests/pages/home.test.jsx index eb6b8ea..ea16e99 100644 --- a/tests/pages/home.test.jsx +++ b/tests/pages/home.test.jsx @@ -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", () => { diff --git a/tests/pages/page-flow.test.jsx b/tests/pages/page-flow.test.jsx index 1d812a8..0dba94b 100644 --- a/tests/pages/page-flow.test.jsx +++ b/tests/pages/page-flow.test.jsx @@ -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"; diff --git a/tests/pages/user-journey.test.jsx b/tests/pages/user-journey.test.jsx index 7b7c927..4233cbd 100644 --- a/tests/pages/user-journey.test.jsx +++ b/tests/pages/user-journey.test.jsx @@ -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"; diff --git a/tests/unit/NumberedCards.test.jsx b/tests/unit/NumberedCards.test.jsx index ee9f0bd..e699567 100644 --- a/tests/unit/NumberedCards.test.jsx +++ b/tests/unit/NumberedCards.test.jsx @@ -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"; diff --git a/tests/unit/QuoteBlock.test.jsx b/tests/unit/QuoteBlock.test.jsx index defe673..08a7656 100644 --- a/tests/unit/QuoteBlock.test.jsx +++ b/tests/unit/QuoteBlock.test.jsx @@ -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"; diff --git a/tests/unit/RuleCard.test.jsx b/tests/unit/RuleCard.test.jsx index 4638da6..ffc04af 100644 --- a/tests/unit/RuleCard.test.jsx +++ b/tests/unit/RuleCard.test.jsx @@ -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"; diff --git a/tests/unit/RuleStack.test.jsx b/tests/unit/RuleStack.test.jsx index bbee808..7d17a3f 100644 --- a/tests/unit/RuleStack.test.jsx +++ b/tests/unit/RuleStack.test.jsx @@ -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"; diff --git a/tests/utils/componentTestSuite.tsx b/tests/utils/componentTestSuite.tsx index 0c0264e..69ebcfe 100644 --- a/tests/utils/componentTestSuite.tsx +++ b/tests/utils/componentTestSuite.tsx @@ -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; diff --git a/tests/utils/test-utils.tsx b/tests/utils/test-utils.tsx new file mode 100644 index 0000000..83c1d35 --- /dev/null +++ b/tests/utils/test-utils.tsx @@ -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, +) { + function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + } + + return render(ui, { wrapper: Wrapper, ...options }); +} + +// Re-export everything from @testing-library/react for convenience +export * from "@testing-library/react";