Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 62 additions & 23 deletions frontend/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,53 +23,64 @@ 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
// renders. Without it the first render can beat init and stick on raw keys.
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.
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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" },
Expand Down
Loading