Skip to content

Commit def8d90

Browse files
sgammonclaude
andcommitted
feat(ui): i18n string layer + MessagesProvider
Break every string a component renders itself (labels, placeholders, ARIA names, status text) out of the components into a default English dictionary (src/i18n/messages.ts), read via useMessages(). Consumers translate the catalog by wrapping in <MessagesProvider messages={locale}> — a PartialMessages dict deep-merged (two levels) over the defaults. Explicit props (e.g. AppNav searchPlaceholder, AiActions title) still override per-instance. Wired: AppNav, MobileNav, SectionTabs, Sidebar, TableOfContents, CodeBlock/ CopyButton, AiActions, SupportMatrix, StatusBadge. Exues MessagesProvider, useMessages, defaultMessages, Messages, PartialMessages. Also: the nav search placeholder default is now "Search or ask" (was "Search or ask AI…"). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 16c7244 commit def8d90

14 files changed

Lines changed: 194 additions & 50 deletions

packages/ui/src/components/ai-actions.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from "react";
22
import { Check, Copy, ExternalLink, Sparkles } from "lucide-react";
33
import { cn } from "../lib/utils";
4+
import { useMessages } from "../i18n/provider";
45

56
/**
67
* AiActions — the "Use with AI" panel in the docs right rail: copy the page as
@@ -27,12 +28,13 @@ const itemClass =
2728
"flex items-center gap-2 rounded-lg border border-border bg-muted px-2.5 py-2 text-left text-[12.5px] font-medium text-foreground no-underline transition-colors hover:bg-accent";
2829

2930
export function AiActions({
30-
title = "Use with AI",
31+
title,
3132
markdown,
3233
actions = [],
3334
className,
3435
...props
3536
}: AiActionsProps) {
37+
const m = useMessages();
3638
const [copied, setCopied] = React.useState(false);
3739

3840
return (
@@ -42,7 +44,7 @@ export function AiActions({
4244
>
4345
<div className="mb-0.5 flex items-center gap-1.5 font-mono text-[11px] font-semibold tracking-wider text-subtle-foreground uppercase">
4446
<Sparkles aria-hidden className="h-[13px] w-[13px] text-[var(--primary-emphasis)]" />
45-
{title}
47+
{title ?? m.aiActions.title}
4648
</div>
4749

4850
{markdown ? (
@@ -62,7 +64,7 @@ export function AiActions({
6264
) : (
6365
<Copy aria-hidden className="h-3.5 w-3.5 shrink-0" />
6466
)}
65-
{copied ? "Copied" : "Copy as Markdown"}
67+
{copied ? m.aiActions.copied : m.aiActions.copyAsMarkdown}
6668
</button>
6769
) : null}
6870

packages/ui/src/components/app-nav.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ChevronDown, Globe, History, Search, Sparkles, Sun } from "lucide-react
33
import { cn } from "../lib/utils";
44
import { Button } from "./button";
55
import { ElideLogo } from "./elide-logo";
6+
import { useMessages } from "../i18n/provider";
67

78
/**
89
* AppNav — the 56px top nav bar present on every docs page: brand + "DOCS"
@@ -36,7 +37,7 @@ const defaultLogo = <ElideLogo />;
3637
export function AppNav({
3738
links,
3839
logo,
39-
searchPlaceholder = "Search or ask AI…",
40+
searchPlaceholder,
4041
locale = "EN",
4142
installHref = "/install",
4243
changelogHref = "/changelog",
@@ -46,9 +47,10 @@ export function AppNav({
4647
className,
4748
...props
4849
}: AppNavProps) {
50+
const m = useMessages();
4951
return (
5052
<nav
51-
aria-label="Main navigation"
53+
aria-label={m.appNav.navLabel}
5254
className={cn(
5355
"flex h-14 items-center gap-[18px] border-b border-border px-[22px] backdrop-blur-xl [background:var(--nav-background)]",
5456
className,
@@ -58,7 +60,7 @@ export function AppNav({
5860
<div className="flex items-center gap-2">
5961
{logo ?? defaultLogo}
6062
<span className="rounded-md border border-border-strong px-[7px] py-0.5 font-mono text-[10px] font-semibold tracking-wider text-muted-foreground">
61-
DOCS
63+
{m.appNav.docsBadge}
6264
</span>
6365
</div>
6466

@@ -87,11 +89,11 @@ export function AppNav({
8789
<button
8890
type="button"
8991
onClick={onSearchClick}
90-
aria-label="Search"
92+
aria-label={m.appNav.searchLabel}
9193
className="flex h-[34px] w-[280px] items-center gap-2 rounded-[9px] border border-border bg-muted px-3 text-subtle-foreground transition-colors hover:border-border-strong"
9294
>
9395
<Search aria-hidden className="h-[15px] w-[15px] shrink-0" />
94-
<span className="flex-1 text-left text-[13px]">{searchPlaceholder}</span>
96+
<span className="flex-1 text-left text-[13px]">{searchPlaceholder ?? m.appNav.searchPlaceholder}</span>
9597
<Sparkles aria-hidden className="h-[13px] w-[13px] shrink-0 text-primary" />
9698
<kbd className="rounded-[5px] border border-border-strong px-1.5 py-px font-mono text-[11px] font-medium">
9799
⌘K
@@ -101,7 +103,7 @@ export function AppNav({
101103
<button
102104
type="button"
103105
onClick={onThemeToggle}
104-
aria-label="Toggle theme"
106+
aria-label={m.appNav.toggleTheme}
105107
className="flex h-[34px] w-[34px] items-center justify-center rounded-[9px] border border-border text-muted-foreground transition-colors hover:bg-[var(--hover)] hover:text-foreground"
106108
>
107109
<Sun aria-hidden className="h-4 w-4" />
@@ -110,7 +112,7 @@ export function AppNav({
110112
<button
111113
type="button"
112114
onClick={onLocaleClick}
113-
aria-label="Language"
115+
aria-label={m.appNav.language}
114116
className="flex h-[34px] items-center gap-1.5 rounded-[9px] border border-border px-[11px] text-[12.5px] font-medium text-muted-foreground transition-colors hover:bg-[var(--hover)] hover:text-foreground"
115117
>
116118
<Globe aria-hidden className="h-[15px] w-[15px]" />
@@ -120,11 +122,11 @@ export function AppNav({
120122

121123
<Button variant="changelog" size="sm" render={<a href={changelogHref} />}>
122124
<History aria-hidden className="h-[15px] w-[15px]" />
123-
Changelog
125+
{m.appNav.changelog}
124126
</Button>
125127

126128
<Button variant="gradient" size="sm" render={<a href={installHref} />}>
127-
Install
129+
{m.appNav.install}
128130
</Button>
129131
</nav>
130132
);

packages/ui/src/components/code-block.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from "react";
22
import { Check, Copy } from "lucide-react";
33
import { Highlight, type PrismTheme } from "prism-react-renderer";
44
import { cn } from "../lib/utils";
5+
import { useMessages } from "../i18n/provider";
56

67
/**
78
* CopyButton — copies `value` to the clipboard, flips to a check for 1.5s.
@@ -10,17 +11,19 @@ import { cn } from "../lib/utils";
1011
export function CopyButton({
1112
value,
1213
className,
13-
label = "Copy",
14+
label,
1415
}: {
1516
value: string;
1617
className?: string;
18+
/** Visible label; omit or pass "" for an icon-only button. Defaults to the localized "Copy". */
1719
label?: string;
1820
}) {
21+
const m = useMessages();
1922
const [copied, setCopied] = React.useState(false);
2023
return (
2124
<button
2225
type="button"
23-
aria-label={copied ? "Copied" : label || "Copy"}
26+
aria-label={copied ? m.codeBlock.copied : label || m.codeBlock.copy}
2427
onClick={() => {
2528
// Swallow clipboard rejections (e.g. no permission in a headless/denied
2629
// context) — the copy is optimistic and a failure shouldn't surface as an
@@ -36,7 +39,7 @@ export function CopyButton({
3639
)}
3740
>
3841
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
39-
{label ? <span>{copied ? "Copied" : label}</span> : null}
42+
{label ? <span>{copied ? m.codeBlock.copied : label}</span> : null}
4043
</button>
4144
);
4245
}
@@ -153,6 +156,7 @@ export function CodeBlock({
153156
statusBar = variant === "editor",
154157
className,
155158
}: CodeBlockProps) {
159+
const m = useMessages();
156160
const trimmed = code.replace(/\n$/, "");
157161
const lines = trimmed.split("\n");
158162
// `children` (pre-highlighted) wins; otherwise highlight the raw code.
@@ -165,7 +169,7 @@ export function CodeBlock({
165169
className="flex h-8 items-center gap-2 border-b px-3.5 font-mono text-[11px] font-semibold uppercase tracking-wider text-[var(--eld-syntax-line-number)]"
166170
style={elev}
167171
>
168-
<span>{lang ?? "Terminal"}</span>
172+
<span>{lang ?? m.codeBlock.terminal}</span>
169173
</div>
170174
<pre className="m-0 overflow-x-auto p-4 font-mono text-[13px] leading-[1.7] text-[var(--eld-syntax-default)]">
171175
{body}

packages/ui/src/components/mobile-nav.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ describe("MobileNav", () => {
5353
const onSearch = vi.fn();
5454
render(<MobileNav groups={groups} onSearch={onSearch} />);
5555

56-
await user.click(screen.getByRole("button", { name: /search or ask ai/i }));
56+
await user.click(screen.getByRole("button", { name: /search or ask/i }));
5757
expect(onSearch).toHaveBeenCalledTimes(1);
5858
});
5959
});

packages/ui/src/components/mobile-nav.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { History, Menu, Search, Sparkle } from "lucide-react";
33
import { cn } from "../lib/utils";
44
import { Button } from "./button";
55
import { Sheet, SheetTrigger, SheetContent } from "./sheet";
6+
import { useMessages } from "../i18n/provider";
67

78
/**
89
* MobileNav — the docs shell at phone width (mockup 3b): a condensed top app
910
* bar, a slide-in section drawer (built on the shipped `Sheet`), and a
10-
* persistent bottom "Search or ask AI" bar. Data-driven and presentational —
11+
* persistent bottom "Search or ask" bar. Data-driven and presentational —
1112
* callers own routing (`href`s) and the search/AI action.
1213
*/
1314
export interface MobileNavItem {
@@ -50,13 +51,14 @@ export function MobileNav({
5051
logo,
5152
onSearch,
5253
changelogHref,
53-
searchPlaceholder = "Search or ask AI…",
54+
searchPlaceholder,
5455
open,
5556
onOpenChange,
5657
className,
5758
children,
5859
...props
5960
}: MobileNavProps) {
61+
const m = useMessages();
6062
const [internalOpen, setInternalOpen] = React.useState(false);
6163
const drawerOpen = open ?? internalOpen;
6264
const setDrawerOpen = React.useCallback(
@@ -76,7 +78,7 @@ export function MobileNav({
7678
<Button
7779
variant="outline"
7880
size="icon"
79-
aria-label="Open navigation"
81+
aria-label={m.mobileNav.openNavigation}
8082
className={cn(
8183
drawerOpen && "border-[var(--primary)] bg-[var(--primary-soft)] text-[var(--primary-emphasis)]",
8284
)}
@@ -86,7 +88,7 @@ export function MobileNav({
8688
<Menu aria-hidden className="h-[18px] w-[18px]" />
8789
</SheetTrigger>
8890
<div className="flex flex-1 items-center gap-2 overflow-hidden">{logo}</div>
89-
<Button variant="outline" size="icon" aria-label="Search" onClick={onSearch}>
91+
<Button variant="outline" size="icon" aria-label={m.mobileNav.searchLabel} onClick={onSearch}>
9092
<Search aria-hidden className="h-[17px] w-[17px]" />
9193
</Button>
9294
</header>
@@ -100,7 +102,7 @@ export function MobileNav({
100102
className="flex h-11 w-full items-center gap-2.5 rounded-xl border border-[var(--border-strong)] bg-[var(--muted)] px-3.5 text-[var(--subtle-foreground)]"
101103
>
102104
<Search aria-hidden className="h-4 w-4 text-[var(--primary-emphasis)]" />
103-
<span className="flex-1 text-left text-sm">{searchPlaceholder}</span>
105+
<span className="flex-1 text-left text-sm">{searchPlaceholder ?? m.mobileNav.searchPlaceholder}</span>
104106
<Sparkle aria-hidden className="h-[15px] w-[15px] text-[var(--primary-emphasis)]" />
105107
</button>
106108
</div>
@@ -109,7 +111,7 @@ export function MobileNav({
109111
<SheetContent
110112
side="left"
111113
showCloseButton={false}
112-
aria-label={section?.title ? `${section.title} navigation` : "Navigation menu"}
114+
aria-label={section?.title ? `${section.title} navigation` : m.mobileNav.dialogLabel}
113115
className="flex w-72 flex-col gap-5 p-4"
114116
>
115117
{section ? (
@@ -131,7 +133,7 @@ export function MobileNav({
131133
</div>
132134
) : null}
133135

134-
<nav aria-label="Mobile navigation" className="flex flex-1 flex-col gap-4 overflow-y-auto">
136+
<nav aria-label={m.mobileNav.navLabel} className="flex flex-1 flex-col gap-4 overflow-y-auto">
135137
{groups.map((group) => (
136138
<div key={group.label} className="flex flex-col gap-1">
137139
<div className="px-2.5 font-mono text-[10.5px] font-semibold uppercase tracking-wider text-[var(--subtle-foreground)]">
@@ -173,7 +175,7 @@ export function MobileNav({
173175
render={changelogHref ? <a href={changelogHref} /> : undefined}
174176
>
175177
<History aria-hidden className="h-[15px] w-[15px]" />
176-
Changelog
178+
{m.mobileNav.changelog}
177179
</Button>
178180
</SheetContent>
179181
</Sheet>

packages/ui/src/components/section-tabs.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from "react";
22
import { cn } from "../lib/utils";
3+
import { useMessages } from "../i18n/provider";
34

45
/**
56
* SectionTabs — the 46px top-level section bar under the main nav (Start ·
@@ -32,9 +33,10 @@ const dotColor: Record<NonNullable<SectionTabsVersion["status"]>, string> = {
3233
};
3334

3435
export function SectionTabs({ items, version, onSelect, className, ...props }: SectionTabsProps) {
36+
const m = useMessages();
3537
return (
3638
<nav
37-
aria-label="Sections"
39+
aria-label={m.sectionTabs.navLabel}
3840
className={cn(
3941
"flex h-[46px] items-center gap-1 border-b border-border bg-background px-[22px]",
4042
className,

packages/ui/src/components/sidebar.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from "react";
22
import { Check, ChevronDown, ExternalLink } from "lucide-react";
33
import { cn } from "../lib/utils";
44
import { Badge } from "./badge";
5+
import { useMessages } from "../i18n/provider";
56
import {
67
DropdownMenu,
78
DropdownMenuContent,
@@ -110,6 +111,7 @@ export interface SidebarItemProps {
110111
}
111112

112113
export function SidebarItem({ label, href, active, external, comingSoon, onSelect }: SidebarItemProps) {
114+
const m = useMessages();
113115
if (comingSoon) {
114116
return (
115117
<span
@@ -118,7 +120,7 @@ export function SidebarItem({ label, href, active, external, comingSoon, onSelec
118120
>
119121
{label}
120122
<Badge variant="neutral" size="sm" className="shrink-0">
121-
Soon
123+
{m.sidebar.comingSoon}
122124
</Badge>
123125
</span>
124126
);
@@ -177,9 +179,10 @@ export function Sidebar({
177179
className,
178180
...props
179181
}: SidebarProps) {
182+
const m = useMessages();
180183
return (
181184
<nav
182-
aria-label="Sidebar"
185+
aria-label={m.sidebar.navLabel}
183186
className={cn("flex w-[264px] flex-col gap-[22px] border-r border-border bg-background p-3.5", className)}
184187
{...props}
185188
>
Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import { Badge, type BadgeProps } from "./badge";
2+
import { useMessages } from "../i18n/provider";
23

34
/**
45
* StatusBadge — thin wrapper over `Badge`'s status tones. Maps an API-support
56
* status (as used across the node:-compatibility reference pages) to a badge
6-
* variant, dot, and default label.
7+
* variant, dot, and default (localized) label.
78
*/
89
export type ApiStatus = "implemented" | "supported" | "partial" | "missing" | "planned";
910

10-
const STATUS_CONFIG: Record<ApiStatus, { variant: NonNullable<BadgeProps["variant"]>; label: string }> = {
11-
implemented: { variant: "supported", label: "Implemented" },
12-
supported: { variant: "supported", label: "Supported" },
13-
partial: { variant: "partial", label: "Partial" },
14-
missing: { variant: "missing", label: "Missing" },
15-
planned: { variant: "missing", label: "Planned" },
11+
const STATUS_VARIANT: Record<ApiStatus, NonNullable<BadgeProps["variant"]>> = {
12+
implemented: "supported",
13+
supported: "supported",
14+
partial: "partial",
15+
missing: "missing",
16+
planned: "missing",
1617
};
1718

1819
export interface StatusBadgeProps extends Omit<BadgeProps, "variant" | "dot"> {
@@ -22,10 +23,10 @@ export interface StatusBadgeProps extends Omit<BadgeProps, "variant" | "dot"> {
2223
}
2324

2425
export function StatusBadge({ status, label, children, ...props }: StatusBadgeProps) {
25-
const { variant, label: defaultLabel } = STATUS_CONFIG[status];
26+
const m = useMessages();
2627
return (
27-
<Badge dot variant={variant} {...props}>
28-
{children ?? label ?? defaultLabel}
28+
<Badge dot variant={STATUS_VARIANT[status]} {...props}>
29+
{children ?? label ?? m.statusBadge[status]}
2930
</Badge>
3031
);
3132
}

0 commit comments

Comments
 (0)