Skip to content

Commit 8b179fb

Browse files
Support translations in Storybook: locale toolbar (#6843)
## What Adds a globe toolbar to Storybook so any story can be previewed in all 42 supported languages (the i18n init already on `main` was English-only). ## How Bundles every locale's `translation.toml` via a `?raw` glob into i18next resources, and switches language on toolbar change. RTL locales (`ar`, `fa`) flip `document.dir`. --------- Co-authored-by: James Brunton <jbrunton96@gmail.com>
1 parent ecddce9 commit 8b179fb

1 file changed

Lines changed: 62 additions & 23 deletions

File tree

frontend/.storybook/preview.tsx

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/// <reference types="vite/client" />
12
// Storybook compiles .storybook/* with the classic JSX runtime, so the JSX in
23
// the decorators below transpiles to React.createElement and needs React in
34
// scope. (The app + story files use the automatic runtime via the portal vite
@@ -22,53 +23,64 @@ import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
2223
import i18next from "i18next";
2324
import { initReactI18next } from "react-i18next";
2425
import { parse as parseToml } from "smol-toml";
25-
// Load the real English copy so stories render human text, not raw keys. Bundled
26-
// synchronously via ?raw so it's present on the very first render (no async flash).
27-
import enTranslationToml from "@public/locales/en-US/translation.toml?raw";
26+
import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
2827

2928
import "@mantine/core/styles.css";
3029
import "@core/tokens/tokens.css";
3130
import "@core/theme/index.css";
3231
import "@core/tokens/base.css";
3332

34-
// Storybook-only: init react-i18next with the real English resources parsed from
35-
// the app's TOML, so t(key) renders the shipped copy (e.g. "No sources connected
36-
// yet") rather than the raw key. Falls back to an empty bundle if parsing ever
37-
// fails, so a malformed TOML can't take the whole Storybook down.
38-
function parseEnTranslation(): Record<string, unknown> {
33+
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
34+
// glob, so the toolbar language switcher can flip between all languages with no
35+
// async fetch (Storybook has no backend to serve /locales/). t(key) then renders
36+
// the shipped copy (e.g. "No sources connected yet") rather than the raw key.
37+
const localeModules = import.meta.glob<string>(
38+
"../editor/public/locales/*/translation.toml",
39+
{ query: "?raw", import: "default", eager: true },
40+
);
41+
42+
// Parse each locale into an i18next resources map. A malformed TOML degrades to
43+
// an empty bundle for that one locale (its keys fall back to en-US) rather than
44+
// taking the whole Storybook down.
45+
const resources: Record<string, { translation: Record<string, unknown> }> = {};
46+
for (const [path, raw] of Object.entries(localeModules)) {
47+
const lng = path.match(/\/locales\/([^/]+)\/translation\.toml$/)?.[1];
48+
if (!lng) continue;
49+
let translation: Record<string, unknown>;
3950
try {
40-
return parseToml(enTranslationToml) as Record<string, unknown>;
51+
translation = parseToml(raw) as Record<string, unknown>;
4152
} catch {
42-
return {};
53+
translation = {};
4354
}
55+
resources[lng] = { translation };
4456
}
4557

46-
const enTranslationResources = parseEnTranslation();
4758
if (!i18next.isInitialized) {
4859
// initImmediate: false → initialise synchronously from the inline resources
4960
// (there's no async backend here), so i18next is ready before the first story
5061
// renders. Without it the first render can beat init and stick on raw keys.
5162
void i18next.use(initReactI18next).init({
5263
lng: "en-US",
5364
fallbackLng: "en-US",
54-
supportedLngs: ["en-US"],
55-
resources: { "en-US": { translation: enTranslationResources } },
65+
supportedLngs: Object.keys(resources),
66+
resources,
5667
interpolation: { escapeValue: false },
5768
react: { useSuspense: false },
5869
initImmediate: false,
5970
});
6071
} else {
6172
// Something initialised i18next first (e.g. the app's async TOML backend):
62-
// inject the shipped English copy under the resolved language so t() renders
63-
// real copy, not raw keys.
64-
const lng = i18next.resolvedLanguage || i18next.language || "en-US";
65-
i18next.addResourceBundle(
66-
lng,
67-
"translation",
68-
enTranslationResources,
69-
true,
70-
true,
71-
);
73+
// inject every shipped locale's copy so t() renders real copy, not raw keys,
74+
// and the toolbar switcher can still change to any of them.
75+
for (const [lng, bundle] of Object.entries(resources)) {
76+
i18next.addResourceBundle(
77+
lng,
78+
"translation",
79+
bundle.translation,
80+
true,
81+
true,
82+
);
83+
}
7284
}
7385

7486
// Start MSW once. Storybook runs in a browser so this uses the service worker.
@@ -156,6 +168,19 @@ function SchemeSetup({ scheme }: { scheme: "light" | "dark" }) {
156168
return null;
157169
}
158170

171+
/** Switches i18next to the toolbar locale and keeps document dir/lang in sync. */
172+
const withLocale: Decorator = (Story, context) => {
173+
const locale = (context.globals.locale as string) ?? "en-US";
174+
useEffect(() => {
175+
void i18next.changeLanguage(locale);
176+
document.documentElement.dir = rtlLanguages.includes(locale)
177+
? "rtl"
178+
: "ltr";
179+
document.documentElement.lang = locale;
180+
}, [locale]);
181+
return <Story />;
182+
};
183+
159184
const withProviders: Decorator = (Story, context) => {
160185
const tier = (context.globals.tier as Tier) ?? "pro";
161186
const linkState =
@@ -243,8 +268,22 @@ const preview: Preview = {
243268
dynamicTitle: true,
244269
},
245270
},
271+
locale: {
272+
name: "Locale",
273+
description: "Active language — drives useTranslation() in all stories",
274+
defaultValue: "en-US",
275+
toolbar: {
276+
icon: "globe",
277+
items: Object.entries(supportedLanguages).map(([value, title]) => ({
278+
value,
279+
title: `${value} - ${title}`,
280+
})),
281+
dynamicTitle: true,
282+
},
283+
},
246284
},
247285
decorators: [
286+
withLocale,
248287
withProviders,
249288
withThemeByDataAttribute({
250289
themes: { light: "light", dark: "dark" },

0 commit comments

Comments
 (0)