refactor(docs-ui): drop DocsI18n.tsx, unify locale handling via next-intl

The cosmetic DocsI18n.tsx shim duplicated the locale catalog that
already lives in config/i18n.json and consumed it through a separate
client hook (useDocsLocale + a 10-entry hard-coded SECTION_LABELS
map). It only ever translated sidebar section titles — the docs
content itself was always English.

Now the /docs page uses the same `LanguageSelector` component as the
rest of the dashboard, backed by next-intl + config/i18n.json. The
locale cookie set there is consumed by the [slug] page server
component (next commit) to actually serve translated markdown.

Removed:
- src/app/docs/components/DocsI18n.tsx (203 lines)

Updated:
- src/app/docs/layout.tsx — swaps DocsLocaleSwitcher for the shared
  LanguageSelector
- tests/unit/docs-site-overhaul.test.ts — replaces the DocsI18n
  importability assertions with checks that (a) the shared next-intl
  config covers the same locales, (b) the global LanguageSelector
  is the new docs switcher, and (c) the DocsI18n module is gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
diegosouzapw
2026-05-13 18:01:53 -03:00
parent fd65db99c8
commit 7b97b01fe6
3 changed files with 25 additions and 217 deletions

View File

@@ -1,200 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
type Locale = "en" | "pt-BR" | "es" | "fr" | "de" | "ja" | "zh-CN" | "ko" | "ru" | "ar";
const SECTION_LABELS: Record<Locale, Record<string, string>> = {
en: {
"Getting Started": "Getting Started",
Features: "Features",
"API & Protocols": "API & Protocols",
Deployment: "Deployment",
Operations: "Operations",
Development: "Development",
},
"pt-BR": {
"Getting Started": "Primeiros Passos",
Features: "Recursos",
"API & Protocols": "API & Protocolos",
Deployment: "Implantação",
Operations: "Operações",
Development: "Desenvolvimento",
},
es: {
"Getting Started": "Primeros Pasos",
Features: "Características",
"API & Protocols": "API y Protocolos",
Deployment: "Despliegue",
Operations: "Operaciones",
Development: "Desarrollo",
},
fr: {
"Getting Started": "Démarrage",
Features: "Fonctionnalités",
"API & Protocols": "API et Protocoles",
Deployment: "Déploiement",
Operations: "Opérations",
Development: "Développement",
},
de: {
"Getting Started": "Erste Schritte",
Features: "Funktionen",
"API & Protocols": "API & Protokolle",
Deployment: "Bereitstellung",
Operations: "Betrieb",
Development: "Entwicklung",
},
ja: {
"Getting Started": "はじめに",
Features: "機能",
"API & Protocols": "APIとプロトコル",
Deployment: "デプロイ",
Operations: "運用",
Development: "開発",
},
"zh-CN": {
"Getting Started": "快速开始",
Features: "功能",
"API & Protocols": "API与协议",
Deployment: "部署",
Operations: "运维",
Development: "开发",
},
ko: {
"Getting Started": "시작하기",
Features: "기능",
"API & Protocols": "API 및 프로토콜",
Deployment: "배포",
Operations: "운영",
Development: "개발",
},
ru: {
"Getting Started": "Начало работы",
Features: "Возможности",
"API & Protocols": "API и Протоколы",
Deployment: "Развертывание",
Operations: "Эксплуатация",
Development: "Разработка",
},
ar: {
"Getting Started": "البدء",
Features: "الميزات",
"API & Protocols": "API والبروتوكولات",
Deployment: "النشر",
Operations: "العمليات",
Development: "التطوير",
},
};
function detectLocale(): Locale {
if (typeof navigator === "undefined") return "en";
const lang = navigator.language;
if (lang.startsWith("pt-BR")) return "pt-BR";
if (lang.startsWith("es")) return "es";
if (lang.startsWith("fr")) return "fr";
if (lang.startsWith("de")) return "de";
if (lang.startsWith("ja")) return "ja";
if (lang.startsWith("zh")) return "zh-CN";
if (lang.startsWith("ko")) return "ko";
if (lang.startsWith("ru")) return "ru";
if (lang.startsWith("ar")) return "ar";
return "en";
}
export function useDocsLocale() {
const searchParams = useSearchParams();
return useMemo<Locale>(() => {
const langParam = searchParams.get("lang");
if (langParam && langParam in SECTION_LABELS) return langParam as Locale;
return detectLocale();
}, [searchParams]);
}
export function useLocalizedSectionTitle(englishTitle: string): string {
const locale = useDocsLocale();
return SECTION_LABELS[locale]?.[englishTitle] ?? englishTitle;
}
export function getAvailableLocales(): Locale[] {
return Object.keys(SECTION_LABELS) as Locale[];
}
export const LOCALE_NAMES: Record<Locale, string> = {
en: "English",
"pt-BR": "Português (Brasil)",
es: "Español",
fr: "Français",
de: "Deutsch",
ja: "日本語",
"zh-CN": "简体中文",
ko: "한국어",
ru: "Русский",
ar: "العربية",
};
const COMMON_LOCALES: Locale[] = ["en", "pt-BR", "es", "fr", "de", "ja", "zh-CN", "ko", "ru", "ar"];
export function DocsLocaleSwitcher() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const currentLocale = useMemo<Locale>(() => {
const langParam = searchParams.get("lang");
if (langParam && langParam in SECTION_LABELS) return langParam as Locale;
if (typeof navigator !== "undefined") {
const navLocale = detectLocale();
if (navLocale in SECTION_LABELS) return navLocale;
}
return "en";
}, [searchParams]);
const handleLocaleChange = (locale: Locale) => {
setIsOpen(false);
const params = new URLSearchParams(searchParams.toString());
if (locale === "en") {
params.delete("lang");
} else {
params.set("lang", locale);
}
const queryString = params.toString();
router.push(`${pathname}${queryString ? `?${queryString}` : ""}`);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 px-2 py-1 text-xs border border-border rounded hover:border-primary/50 transition-colors text-text-muted hover:text-text-main"
aria-label="Change documentation language"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<span className="material-symbols-outlined text-sm">language</span>
{LOCALE_NAMES[currentLocale]}
</button>
{isOpen && (
<div
className="absolute right-0 top-full mt-1 bg-bg border border-border rounded-lg shadow-lg z-50 py-1 min-w-[160px]"
role="listbox"
>
{COMMON_LOCALES.map((locale) => (
<button
key={locale}
onClick={() => handleLocaleChange(locale)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-primary/5 transition-colors ${
locale === currentLocale ? "text-primary font-semibold" : "text-text-muted"
}`}
role="option"
aria-selected={locale === currentLocale}
>
{LOCALE_NAMES[locale]}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
import { ReactNode, Suspense } from "react";
import { DocsSidebarClient } from "./components/DocsSidebarClient";
import { DocsLocaleSwitcher } from "./components/DocsI18n";
import LanguageSelector from "@/shared/components/LanguageSelector";
export const metadata = {
title: {
@@ -56,7 +56,7 @@ export default function DocsLayout({ children }: { children: ReactNode }) {
</Link>
</div>
<Suspense fallback={<div className="w-24 h-8" />}>
<DocsLocaleSwitcher />
<LanguageSelector />
</Suspense>
</nav>

View File

@@ -314,23 +314,31 @@ test("WhatsNewSection is importable", async () => {
});
// ──────────────────────────────────────────────
// i18n locale system
// i18n locale system (next-intl + config/i18n.json)
// ──────────────────────────────────────────────
test("DocsI18n is importable", async () => {
const mod = await import("../../src/app/docs/components/DocsI18n");
assert.ok(mod.useLocalizedSectionTitle, "useLocalizedSectionTitle should be exported");
assert.ok(mod.getAvailableLocales, "getAvailableLocales should be exported");
assert.ok(mod.LOCALE_NAMES, "LOCALE_NAMES should be exported");
assert.equal(mod.LOCALE_NAMES.en, "English");
assert.equal(mod.LOCALE_NAMES["zh-CN"], "简体中文");
test("docs locale handling uses the shared next-intl config", async () => {
const cfg = await import("../../src/i18n/config");
assert.ok(Array.isArray(cfg.LANGUAGES), "LANGUAGES should be exported");
assert.ok(cfg.LANGUAGES.length >= 10, "LANGUAGES should cover all configured locales");
const codes = cfg.LANGUAGES.map((l) => l.code);
assert.ok(codes.includes("en"));
assert.ok(codes.includes("pt-BR"));
assert.ok(codes.includes("zh-CN"));
const en = cfg.LANGUAGES.find((l) => l.code === "en");
const zh = cfg.LANGUAGES.find((l) => l.code === "zh-CN");
assert.equal(en?.english, "English");
assert.equal(zh?.native, "中文 (简体)");
});
test("getAvailableLocales returns 10 locales", async () => {
const { getAvailableLocales } = await import("../../src/app/docs/components/DocsI18n");
const locales = getAvailableLocales();
assert.equal(locales.length, 10);
assert.ok(locales.includes("en"));
assert.ok(locales.includes("pt-BR"));
assert.ok(locales.includes("zh-CN"));
test("docs language selector reuses the global LanguageSelector component", async () => {
const selector = await import("../../src/shared/components/LanguageSelector");
assert.ok(selector.default, "LanguageSelector default export should exist");
});
test("DocsI18n shim is no longer present (replaced by next-intl)", async () => {
await assert.rejects(
async () => import("../../src/app/docs/components/DocsI18n"),
"DocsI18n.tsx should be removed; docs UI uses next-intl directly"
);
});