Skip to content

Commit e129f09

Browse files
sleitorhaydenbleaselclaude
authored
feat: add translations prop for i18n support (#415)
* feat: add translations/i18n support via translations prop Implements #190 - adds a `translations` prop to the Streamdown component that allows customizing all UI strings for internationalization. Changes: - Add `packages/streamdown/lib/translations-context.tsx` with StreamdownTranslations type, defaultTranslations, TranslationsContext, and useTranslations hook - Add `translations?: Partial<StreamdownTranslations>` prop to Streamdown - Wrap render output with TranslationsContext.Provider (both static and streaming modes) - Export StreamdownTranslations type and defaultTranslations from index - Replace all hardcoded UI strings in sub-components with translations: - code-block/copy-button: copyCode - code-block/download-button: downloadFile - mermaid/download-button: downloadDiagram, downloadDiagramAs{Svg,Png,Mmd}, mermaidFormat{Svg,Png,Mmd} - mermaid/fullscreen-button: viewFullscreen, exitFullscreen - table/copy-dropdown: copyTable, copyTableAs{Markdown,Csv,Tsv}, tableFormat{Markdown,Csv,Tsv} - table/download-dropdown: downloadTable, downloadTableAs{Csv,Markdown}, tableFormat{Csv,Markdown} - image: imageNotAvailable, downloadImage - link-modal: openExternalLink, externalLinkWarning, close, copyLink, copied, openLink - Add __tests__/translations.test.tsx with 12 tests covering defaults, custom translations, partial overrides, and context access - Update download-dropdown.test.tsx to match new translation-based title All 804 tests pass. * Add changeset for translations prop * Fix misplaced comments in translations type and broken test type assertion Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use value equality for translations memo to support inline objects Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Troitskii <jsleitor@gmail.com> Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 57cd3b5 commit e129f09

File tree

13 files changed

+520
-90
lines changed

13 files changed

+520
-90
lines changed

.changeset/translations-prop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"streamdown": patch
3+
---
4+
5+
Add `translations` prop for i18n support

packages/streamdown/__tests__/download-dropdown.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ describe("TableDownloadButton", () => {
245245
);
246246

247247
const button = buttonDiv.querySelector("button");
248-
expect(button?.getAttribute("title")).toContain("MARKDOWN");
248+
expect(button?.getAttribute("title")).toContain("Markdown");
249249

250250
expect(button).toBeTruthy();
251251
if (button) {
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { fireEvent, render, waitFor } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { defaultTranslations, Streamdown } from "../index";
4+
import { ImageComponent } from "../lib/image";
5+
import { LinkSafetyModal } from "../lib/link-modal";
6+
import { TranslationsContext } from "../lib/translations-context";
7+
8+
const markdownWithCode = `
9+
\`\`\`javascript
10+
console.log("hello");
11+
\`\`\`
12+
`;
13+
14+
const markdownWithTable = `
15+
| Name | Value |
16+
|------|-------|
17+
| Foo | Bar |
18+
`;
19+
20+
describe("defaultTranslations", () => {
21+
it("should export defaultTranslations with all required keys", () => {
22+
expect(defaultTranslations.copyCode).toBe("Copy Code");
23+
expect(defaultTranslations.downloadFile).toBe("Download file");
24+
expect(defaultTranslations.downloadDiagram).toBe("Download diagram");
25+
expect(defaultTranslations.downloadDiagramAsSvg).toBe(
26+
"Download diagram as SVG"
27+
);
28+
expect(defaultTranslations.downloadDiagramAsPng).toBe(
29+
"Download diagram as PNG"
30+
);
31+
expect(defaultTranslations.downloadDiagramAsMmd).toBe(
32+
"Download diagram as MMD"
33+
);
34+
expect(defaultTranslations.viewFullscreen).toBe("View fullscreen");
35+
expect(defaultTranslations.exitFullscreen).toBe("Exit fullscreen");
36+
expect(defaultTranslations.mermaidFormatSvg).toBe("SVG");
37+
expect(defaultTranslations.mermaidFormatPng).toBe("PNG");
38+
expect(defaultTranslations.mermaidFormatMmd).toBe("MMD");
39+
expect(defaultTranslations.copyTable).toBe("Copy table");
40+
expect(defaultTranslations.copyTableAsMarkdown).toBe(
41+
"Copy table as Markdown"
42+
);
43+
expect(defaultTranslations.copyTableAsCsv).toBe("Copy table as CSV");
44+
expect(defaultTranslations.copyTableAsTsv).toBe("Copy table as TSV");
45+
expect(defaultTranslations.downloadTable).toBe("Download table");
46+
expect(defaultTranslations.downloadTableAsCsv).toBe(
47+
"Download table as CSV"
48+
);
49+
expect(defaultTranslations.downloadTableAsMarkdown).toBe(
50+
"Download table as Markdown"
51+
);
52+
expect(defaultTranslations.tableFormatMarkdown).toBe("Markdown");
53+
expect(defaultTranslations.tableFormatCsv).toBe("CSV");
54+
expect(defaultTranslations.tableFormatTsv).toBe("TSV");
55+
expect(defaultTranslations.imageNotAvailable).toBe("Image not available");
56+
expect(defaultTranslations.downloadImage).toBe("Download image");
57+
expect(defaultTranslations.openExternalLink).toBe("Open external link?");
58+
expect(defaultTranslations.externalLinkWarning).toBe(
59+
"You're about to visit an external website."
60+
);
61+
expect(defaultTranslations.close).toBe("Close");
62+
expect(defaultTranslations.copyLink).toBe("Copy link");
63+
expect(defaultTranslations.copied).toBe("Copied");
64+
expect(defaultTranslations.openLink).toBe("Open link");
65+
});
66+
});
67+
68+
describe("Streamdown translations prop", () => {
69+
it("should use default translations when no translations prop is provided", async () => {
70+
const { container } = render(<Streamdown>{markdownWithCode}</Streamdown>);
71+
72+
await waitFor(() => {
73+
const copyButton = container.querySelector(
74+
'[data-streamdown="code-block-copy-button"]'
75+
);
76+
expect(copyButton).toBeTruthy();
77+
expect(copyButton?.getAttribute("title")).toBe("Copy Code");
78+
});
79+
});
80+
81+
it("should use custom translations for code block copy button", async () => {
82+
const { container } = render(
83+
<Streamdown translations={{ copyCode: "Kopieren" }}>
84+
{markdownWithCode}
85+
</Streamdown>
86+
);
87+
88+
await waitFor(() => {
89+
const copyButton = container.querySelector(
90+
'[data-streamdown="code-block-copy-button"]'
91+
);
92+
expect(copyButton).toBeTruthy();
93+
expect(copyButton?.getAttribute("title")).toBe("Kopieren");
94+
});
95+
});
96+
97+
it("should use custom translations for code block download button", async () => {
98+
const { container } = render(
99+
<Streamdown translations={{ downloadFile: "Datei herunterladen" }}>
100+
{markdownWithCode}
101+
</Streamdown>
102+
);
103+
104+
await waitFor(() => {
105+
const downloadButton = container.querySelector(
106+
'[data-streamdown="code-block-download-button"]'
107+
);
108+
expect(downloadButton).toBeTruthy();
109+
expect(downloadButton?.getAttribute("title")).toBe("Datei herunterladen");
110+
});
111+
});
112+
113+
it("should use custom translations for table copy button", () => {
114+
const { container } = render(
115+
<Streamdown translations={{ copyTable: "Tabelle kopieren" }}>
116+
{markdownWithTable}
117+
</Streamdown>
118+
);
119+
120+
const tableWrapper = container.querySelector(
121+
'[data-streamdown="table-wrapper"]'
122+
);
123+
expect(tableWrapper).toBeTruthy();
124+
125+
const buttons = tableWrapper?.querySelectorAll("button");
126+
const copyButton = Array.from(buttons ?? []).find(
127+
(btn) => btn.getAttribute("title") === "Tabelle kopieren"
128+
);
129+
expect(copyButton).toBeTruthy();
130+
});
131+
132+
it("should support partial translations (only override specific keys)", async () => {
133+
const { container } = render(
134+
<Streamdown translations={{ copyCode: "コピー" }}>
135+
{markdownWithCode}
136+
</Streamdown>
137+
);
138+
139+
await waitFor(() => {
140+
const copyButton = container.querySelector(
141+
'[data-streamdown="code-block-copy-button"]'
142+
);
143+
expect(copyButton?.getAttribute("title")).toBe("コピー");
144+
145+
// Other defaults should still be present
146+
const downloadButton = container.querySelector(
147+
'[data-streamdown="code-block-download-button"]'
148+
);
149+
expect(downloadButton?.getAttribute("title")).toBe("Download file");
150+
});
151+
});
152+
});
153+
154+
describe("ImageComponent translations", () => {
155+
it("should show default 'Image not available' text when image fails to load", async () => {
156+
const { container } = render(
157+
<ImageComponent alt="test" src="https://example.invalid/image.png" />
158+
);
159+
160+
const img = container.querySelector("img");
161+
expect(img).toBeTruthy();
162+
163+
if (img) {
164+
fireEvent.error(img);
165+
}
166+
167+
await waitFor(() => {
168+
const fallback = container.querySelector(
169+
'[data-streamdown="image-fallback"]'
170+
);
171+
expect(fallback).toBeTruthy();
172+
expect(fallback?.textContent).toBe("Image not available");
173+
});
174+
});
175+
176+
it("should use custom translation for image not available text", async () => {
177+
const { container } = render(
178+
<TranslationsContext.Provider
179+
value={{
180+
...defaultTranslations,
181+
imageNotAvailable: "Bild nicht verfügbar",
182+
}}
183+
>
184+
<ImageComponent alt="test" src="https://example.invalid/image.png" />
185+
</TranslationsContext.Provider>
186+
);
187+
188+
const img = container.querySelector("img");
189+
if (img) {
190+
fireEvent.error(img);
191+
}
192+
193+
await waitFor(() => {
194+
const fallback = container.querySelector(
195+
'[data-streamdown="image-fallback"]'
196+
);
197+
expect(fallback?.textContent).toBe("Bild nicht verfügbar");
198+
});
199+
});
200+
});
201+
202+
describe("LinkSafetyModal translations", () => {
203+
it("should show default translations in link safety modal", () => {
204+
const { container } = render(
205+
<LinkSafetyModal
206+
isOpen={true}
207+
onClose={vi.fn()}
208+
onConfirm={vi.fn()}
209+
url="https://example.com"
210+
/>
211+
);
212+
213+
expect(container.textContent).toContain("Open external link?");
214+
expect(container.textContent).toContain(
215+
"You're about to visit an external website."
216+
);
217+
expect(container.textContent).toContain("Copy link");
218+
expect(container.textContent).toContain("Open link");
219+
});
220+
221+
it("should use custom translations in link safety modal", () => {
222+
const customTranslations = {
223+
...defaultTranslations,
224+
openExternalLink: "Externen Link öffnen?",
225+
externalLinkWarning: "Sie besuchen eine externe Website.",
226+
copyLink: "Link kopieren",
227+
openLink: "Link öffnen",
228+
close: "Schließen",
229+
};
230+
231+
const { container } = render(
232+
<TranslationsContext.Provider value={customTranslations}>
233+
<LinkSafetyModal
234+
isOpen={true}
235+
onClose={vi.fn()}
236+
onConfirm={vi.fn()}
237+
url="https://example.com"
238+
/>
239+
</TranslationsContext.Provider>
240+
);
241+
242+
expect(container.textContent).toContain("Externen Link öffnen?");
243+
expect(container.textContent).toContain(
244+
"Sie besuchen eine externe Website."
245+
);
246+
expect(container.textContent).toContain("Link kopieren");
247+
expect(container.textContent).toContain("Link öffnen");
248+
});
249+
});
250+
251+
describe("TranslationsContext", () => {
252+
it("should be accessible from TranslationsContext directly", () => {
253+
let capturedTranslations: string | undefined;
254+
255+
const TestConsumer = () => {
256+
return (
257+
<TranslationsContext.Consumer>
258+
{(value) => {
259+
capturedTranslations = value.copyCode;
260+
return null;
261+
}}
262+
</TranslationsContext.Consumer>
263+
);
264+
};
265+
266+
render(<TestConsumer />);
267+
expect(capturedTranslations).toBe("Copy Code");
268+
});
269+
270+
it("should provide custom values via TranslationsContext.Provider", () => {
271+
let capturedValue: string | undefined;
272+
273+
const TestConsumer = () => {
274+
return (
275+
<TranslationsContext.Consumer>
276+
{(value) => {
277+
capturedValue = value.copyCode;
278+
return null;
279+
}}
280+
</TranslationsContext.Consumer>
281+
);
282+
};
283+
284+
render(
285+
<TranslationsContext.Provider
286+
value={{ ...defaultTranslations, copyCode: "Custom Copy" }}
287+
>
288+
<TestConsumer />
289+
</TranslationsContext.Provider>
290+
);
291+
292+
expect(capturedValue).toBe("Custom Copy");
293+
});
294+
});

0 commit comments

Comments
 (0)