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>
This commit is contained in:
Reece Browne
2026-07-17 15:01:58 +00:00
committed by GitHub
co-authored by James Brunton
parent ecddce9944
commit 8b179fbc55
+62 -23
View File
@@ -1,3 +1,4 @@
/// <reference types="vite/client" />
// Storybook compiles .storybook/* with the classic JSX runtime, so the JSX in
// the decorators below transpiles to React.createElement and needs React in
// scope. (The app + story files use the automatic runtime via the portal vite
@@ -22,28 +23,38 @@ import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import { parse as parseToml } from "smol-toml";
// Load the real English copy so stories render human text, not raw keys. Bundled
// synchronously via ?raw so it's present on the very first render (no async flash).
import enTranslationToml from "@public/locales/en-US/translation.toml?raw";
import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
import "@mantine/core/styles.css";
import "@core/tokens/tokens.css";
import "@core/theme/index.css";
import "@core/tokens/base.css";
// Storybook-only: init react-i18next with the real English resources parsed from
// the app's TOML, so t(key) renders the shipped copy (e.g. "No sources connected
// yet") rather than the raw key. Falls back to an empty bundle if parsing ever
// fails, so a malformed TOML can't take the whole Storybook down.
function parseEnTranslation(): Record<string, unknown> {
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
// glob, so the toolbar language switcher can flip between all languages with no
// async fetch (Storybook has no backend to serve /locales/). t(key) then renders
// the shipped copy (e.g. "No sources connected yet") rather than the raw key.
const localeModules = import.meta.glob<string>(
"../editor/public/locales/*/translation.toml",
{ query: "?raw", import: "default", eager: true },
);
// Parse each locale into an i18next resources map. A malformed TOML degrades to
// an empty bundle for that one locale (its keys fall back to en-US) rather than
// taking the whole Storybook down.
const resources: Record<string, { translation: Record<string, unknown> }> = {};
for (const [path, raw] of Object.entries(localeModules)) {
const lng = path.match(/\/locales\/([^/]+)\/translation\.toml$/)?.[1];
if (!lng) continue;
let translation: Record<string, unknown>;
try {
return parseToml(enTranslationToml) as Record<string, unknown>;
translation = parseToml(raw) as Record<string, unknown>;
} catch {
return {};
translation = {};
}
resources[lng] = { translation };
}
const enTranslationResources = parseEnTranslation();
if (!i18next.isInitialized) {
// initImmediate: false → initialise synchronously from the inline resources
// (there's no async backend here), so i18next is ready before the first story
@@ -51,24 +62,25 @@ if (!i18next.isInitialized) {
void i18next.use(initReactI18next).init({
lng: "en-US",
fallbackLng: "en-US",
supportedLngs: ["en-US"],
resources: { "en-US": { translation: enTranslationResources } },
supportedLngs: Object.keys(resources),
resources,
interpolation: { escapeValue: false },
react: { useSuspense: false },
initImmediate: false,
});
} else {
// Something initialised i18next first (e.g. the app's async TOML backend):
// inject the shipped English copy under the resolved language so t() renders
// real copy, not raw keys.
const lng = i18next.resolvedLanguage || i18next.language || "en-US";
i18next.addResourceBundle(
lng,
"translation",
enTranslationResources,
true,
true,
);
// inject every shipped locale's copy so t() renders real copy, not raw keys,
// and the toolbar switcher can still change to any of them.
for (const [lng, bundle] of Object.entries(resources)) {
i18next.addResourceBundle(
lng,
"translation",
bundle.translation,
true,
true,
);
}
}
// Start MSW once. Storybook runs in a browser so this uses the service worker.
@@ -156,6 +168,19 @@ function SchemeSetup({ scheme }: { scheme: "light" | "dark" }) {
return null;
}
/** Switches i18next to the toolbar locale and keeps document dir/lang in sync. */
const withLocale: Decorator = (Story, context) => {
const locale = (context.globals.locale as string) ?? "en-US";
useEffect(() => {
void i18next.changeLanguage(locale);
document.documentElement.dir = rtlLanguages.includes(locale)
? "rtl"
: "ltr";
document.documentElement.lang = locale;
}, [locale]);
return <Story />;
};
const withProviders: Decorator = (Story, context) => {
const tier = (context.globals.tier as Tier) ?? "pro";
const linkState =
@@ -243,8 +268,22 @@ const preview: Preview = {
dynamicTitle: true,
},
},
locale: {
name: "Locale",
description: "Active language — drives useTranslation() in all stories",
defaultValue: "en-US",
toolbar: {
icon: "globe",
items: Object.entries(supportedLanguages).map(([value, title]) => ({
value,
title: `${value} - ${title}`,
})),
dynamicTitle: true,
},
},
},
decorators: [
withLocale,
withProviders,
withThemeByDataAttribute({
themes: { light: "light", dark: "dark" },