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";
2223import i18next from "i18next" ;
2324import { initReactI18next } from "react-i18next" ;
2425import { 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
2928import "@mantine/core/styles.css" ;
3029import "@core/tokens/tokens.css" ;
3130import "@core/theme/index.css" ;
3231import "@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 ( / \/ l o c a l e s \/ ( [ ^ / ] + ) \/ t r a n s l a t i o n \. t o m l $ / ) ?. [ 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 ( ) ;
4758if ( ! 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+
159184const 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