From 94cd8f760958200848c9a0085ae082cbfcc88f73 Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Mon, 29 Jun 2026 11:57:32 -0400 Subject: [PATCH 01/12] feat(i18n): add loco package integration and live sync in Storybook --- .storybook/preview.tsx | 178 +++++++++++++++++- package.json | 1 + pnpm-lock.yaml | 33 +--- scripts/sync-loco-pack.mjs | 72 +++++++ .../AppHeaderI18nIntegration.stories.tsx | 109 +++++++++++ src/components/Badge/Badge.stories.tsx | 84 ++++++--- .../CountBadge/CountBadge.stories.tsx | 44 +++-- .../Text/TextI18nIntegration.stories.tsx | 66 +++++++ src/i18n/loco-sample-pack.json | 34 ++++ src/utils/i18n.test.ts | 60 ++++++ src/utils/i18n.ts | 94 +++++++++ src/utils/index.ts | 2 + src/utils/loco-live.ts | 104 ++++++++++ 13 files changed, 810 insertions(+), 71 deletions(-) create mode 100644 scripts/sync-loco-pack.mjs create mode 100644 src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx create mode 100644 src/components/Text/TextI18nIntegration.stories.tsx create mode 100644 src/i18n/loco-sample-pack.json create mode 100644 src/utils/i18n.test.ts create mode 100644 src/utils/i18n.ts create mode 100644 src/utils/loco-live.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 06aa7d8eb..e70b20342 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -14,6 +14,69 @@ import { ozwellBrand } from '../src/brands/ozwell'; import { wagglelineBrand } from '../src/brands/waggleline'; import { webchartBrand } from '../src/brands/webchart'; import type { BrandConfig } from '../src/brands/types'; +import { collectLocoKeysFromElement, postLocoTextnodes } from '../src/utils/loco-live'; + +const postedLiveSyncSignatures = new Set(); +const locoScriptLoaders = new Map>(); +const locoLiveInitKeys = new Set(); + +type LocoRuntime = { + init?: (config: { apiUrl: string; apiKey?: string }) => Promise | unknown; + apply?: (lang: string) => Promise | unknown; + restore?: () => Promise | unknown; +}; + +async function ensureLocoRuntimeLoaded(serverUrl: string): Promise { + if (typeof window === 'undefined') return null; + + const runtime = (window as any).Loco as LocoRuntime | undefined; + if (runtime?.init) return runtime; + + const serverBase = serverUrl.replace(/\/$/, ''); + const scriptId = `loco-runtime-${serverBase}`; + + let loader = locoScriptLoaders.get(scriptId); + if (!loader) { + loader = new Promise((resolve, reject) => { + const existing = document.getElementById(scriptId) as HTMLScriptElement | null; + if (existing) { + existing.addEventListener('load', () => resolve(), { once: true }); + existing.addEventListener('error', () => reject(new Error('Failed to load Loco runtime script.')), { once: true }); + return; + } + + const script = document.createElement('script'); + script.id = scriptId; + script.src = `${serverBase}/cdn/loco.js`; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Unable to load ${script.src}`)); + document.head.appendChild(script); + }); + locoScriptLoaders.set(scriptId, loader); + } + + await loader; + return ((window as any).Loco as LocoRuntime | undefined) ?? null; +} + +async function ensureLocoLiveInitialized(serverUrl: string, apiKey?: string): Promise { + const runtime = await ensureLocoRuntimeLoaded(serverUrl); + if (!runtime?.init) return runtime; + + const serverBase = serverUrl.replace(/\/$/, ''); + const initKey = `${serverBase}|${apiKey || ''}`; + if (locoLiveInitKeys.has(initKey)) return runtime; + + await Promise.resolve( + runtime.init({ + apiUrl: serverBase, + apiKey, + }), + ); + locoLiveInitKeys.add(initKey); + return runtime; +} // Map of available brands const brands: Record = { @@ -243,11 +306,98 @@ const withBrand: Decorator = (Story, context) => { ); }; +const withLocoLiveSync: Decorator = (Story, context) => { + const locoMode = String(context.globals?.locoMode || 'package'); + const locale = String(context.globals?.locale || 'en'); + const configuredServer = String(context.globals?.locoServer || '').trim(); + const configuredApiKey = String(context.globals?.locoApiKey || '').trim(); + const serverFromEnv = + (import.meta.env?.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || + 'http://localhost:6101'; + const serverUrl = configuredServer || serverFromEnv; + const apiKey = + configuredApiKey || + (import.meta.env?.VITE_LOCO_API_KEY as string | undefined)?.trim() || + undefined; + + useEffect(() => { + if (locoMode !== 'live') return; + + const root = document.querySelector('[data-loco-scan-root="true"]') as HTMLElement | null; + if (!root) return; + + const keys = collectLocoKeysFromElement(root); + if (keys.length === 0) return; + + const signature = `${context.id}:${serverUrl}:${keys.map((entry) => entry.key).join('|')}`; + if (postedLiveSyncSignatures.has(signature)) return; + postedLiveSyncSignatures.add(signature); + + void postLocoTextnodes({ + serverUrl, + keys, + pageUrl: window.location.href, + apiKey, + }).catch((error) => { + console.warn( + '[loco-live-sync] Unable to post phrases to Loco. Check VITE_LOCO_SERVER_URL and VITE_LOCO_API_KEY.', + error, + ); + }); + }, [locoMode, serverUrl, apiKey, context.id]); + + useEffect(() => { + let cancelled = false; + + // If switching back to package mode, undo live runtime translations. + if (locoMode !== 'live') { + const runtime = (window as any).Loco as LocoRuntime | undefined; + if (runtime?.restore) { + void Promise.resolve(runtime.restore()).catch(() => undefined); + } + return; + } + + void (async () => { + const runtime = await ensureLocoLiveInitialized(serverUrl, apiKey); + if (!runtime || cancelled) return; + + // Keep English as the baseline original text in Storybook. + if (locale === 'en') { + if (runtime.restore) { + await Promise.resolve(runtime.restore()); + } + return; + } + + if (runtime.apply) { + await Promise.resolve(runtime.apply(locale)); + } + })().catch((error) => { + console.warn('[loco-live-sync] Unable to apply live locale with Loco runtime.', error); + }); + + return () => { + cancelled = true; + }; + }, [locoMode, locale, serverUrl, apiKey, context.id]); + + return ( +
+ +
+ ); +}; + const preview: Preview = { initialGlobals: { brand: 'bluehive', theme: 'light', density: 'standard', + locale: 'en', + locoMode: 'package', + locoServer: 'http://localhost:6101', + locoApiKey: '82b6c1a44ec247dcb6c96fe0', }, globalTypes: { brand: { @@ -292,6 +442,32 @@ const preview: Preview = { dynamicTitle: true, }, }, + locale: { + name: 'Locale', + description: 'Locale used by i18n integration stories', + toolbar: { + icon: 'globe', + items: [ + { value: 'en', title: 'English (en)' }, + { value: 'fr', title: 'French (fr)' }, + { value: 'zh-Hans', title: 'Chinese (zh-Hans)' }, + ], + dynamicTitle: true, + }, + }, + locoMode: { + name: 'Loco i18n', + description: + 'Use Loco i18n package for preview or Loco Sync Text to post discovered phrases to Loco pending list.', + toolbar: { + icon: 'transfer', + items: [ + { value: 'package', title: 'Loco i18n' }, + { value: 'live', title: 'Loco Sync Text' }, + ], + dynamicTitle: true, + }, + }, }, parameters: { a11y: { @@ -344,7 +520,7 @@ const preview: Preview = { }, }, }, - decorators: [withGitHubSource, withBrand], + decorators: [withGitHubSource, withBrand, withLocoLiveSync], }; export default preview; diff --git a/package.json b/package.json index 387d91e55..905d0b7d9 100644 --- a/package.json +++ b/package.json @@ -221,6 +221,7 @@ "test:watch": "vitest", "prestorybook": "npm run build:esheet", "storybook": "storybook dev -p 6006", + "loco:pack:sync": "node scripts/sync-loco-pack.mjs", "build-storybook": "npm run build:esheet && storybook build", "test:coverage": "vitest run --coverage", "playwright:install": "playwright install --with-deps", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b18c2581..198d97843 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -307,7 +307,7 @@ importers: version: 7.12.1 ychart: specifier: file:./packages/ychart - version: '@mieweb/ychart@file:packages/ychart(@popperjs/core@2.11.8)' + version: file:packages/ychart zod: specifier: ^4.4.3 version: 4.4.3 @@ -1111,10 +1111,6 @@ packages: wavesurfer.js: optional: true - '@mieweb/ychart@file:packages/ychart': - resolution: {directory: packages/ychart, type: directory} - engines: {node: '>=24.0.0'} - '@monaco-editor/loader@1.7.0': resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} @@ -5850,6 +5846,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + ychart@file:packages/ychart: + resolution: {directory: packages/ychart, type: directory} + yjs@13.6.30: resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -6921,28 +6920,6 @@ snapshots: papaparse: 5.5.3 wavesurfer.js: 7.12.1 - '@mieweb/ychart@file:packages/ychart(@popperjs/core@2.11.8)': - dependencies: - '@codemirror/lang-yaml': 6.1.3 - '@codemirror/lint': 6.9.5 - '@codemirror/state': 6.6.0 - '@codemirror/theme-one-dark': 6.1.3 - '@codemirror/view': 6.43.0 - bootstrap: 5.3.8(@popperjs/core@2.11.8) - codemirror: 6.0.2 - d3: 7.9.0 - d3-array: 3.2.4 - d3-drag: 3.0.0 - d3-flextree: 2.1.2 - d3-hierarchy: 3.1.2 - d3-org-chart: 3.1.1 - d3-selection: 3.0.0 - d3-shape: 3.2.0 - d3-zoom: 3.0.0 - js-yaml: 4.1.1 - transitivePeerDependencies: - - '@popperjs/core' - '@monaco-editor/loader@1.7.0': dependencies: state-local: 1.0.7 @@ -12511,6 +12488,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + ychart@file:packages/ychart: {} + yjs@13.6.30: dependencies: lib0: 0.2.117 diff --git a/scripts/sync-loco-pack.mjs b/scripts/sync-loco-pack.mjs new file mode 100644 index 000000000..9726e3d97 --- /dev/null +++ b/scripts/sync-loco-pack.mjs @@ -0,0 +1,72 @@ +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +function parseArgs(argv) { + const options = {}; + for (const raw of argv) { + if (!raw.startsWith('--')) continue; + const [key, value] = raw.slice(2).split('='); + if (!key) continue; + options[key] = value ?? 'true'; + } + return options; +} + +function printHelp() { + console.log(`Usage: node scripts/sync-loco-pack.mjs [options] + +Options: + --server= Loco server URL (default: http://localhost:6101) + --apiKey= Loco API key (or set LOCO_API_KEY) + --out= Output file path (default: src/i18n/loco-sample-pack.json) + --format= Export format (default: i18next-nested) + --contextMode= Context mode (default: ignore) + --help Show this message + +Environment variables: + LOCO_SERVER_URL + LOCO_API_KEY +`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help === 'true') { + printHelp(); + return; + } + const server = (args.server || process.env.LOCO_SERVER_URL || 'http://localhost:6101').replace(/\/$/, ''); + const apiKey = args.apiKey || process.env.LOCO_API_KEY || ''; + const outPath = args.out || 'src/i18n/loco-sample-pack.json'; + const format = args.format || 'i18next-nested'; + const contextMode = args.contextMode || 'ignore'; + + const exportUrl = new URL(`${server}/api/export`); + exportUrl.searchParams.set('format', format); + exportUrl.searchParams.set('contextMode', contextMode); + + const headers = {}; + if (apiKey) { + headers['x-api-key'] = apiKey; + } + + console.log(`[loco-sync] Fetching ${exportUrl.toString()}`); + const response = await fetch(exportUrl, { headers }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Loco export failed (${response.status}): ${body}`); + } + + const payload = await response.json(); + const output = `${JSON.stringify(payload, null, 2)}\n`; + const absoluteOut = path.resolve(process.cwd(), outPath); + await writeFile(absoluteOut, output, 'utf8'); + + console.log(`[loco-sync] Wrote package to ${absoluteOut}`); +} + +main().catch((error) => { + console.error('[loco-sync] Failed:', error.message); + process.exit(1); +}); diff --git a/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx b/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx new file mode 100644 index 000000000..52952cc79 --- /dev/null +++ b/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx @@ -0,0 +1,109 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useEffect, useState } from 'react'; +import { + AppHeader, + AppHeaderActions, + AppHeaderBrand, + AppHeaderDivider, + AppHeaderIconButton, + AppHeaderSearch, + AppHeaderSection, + AppHeaderTitle, + AppHeaderUserMenu, +} from './index'; +import locoSamplePack from '../../i18n/loco-sample-pack.json'; +import { createLocoTranslator } from '../../utils/i18n'; + +const BellIcon = () => ( + + + +); + +const MessageIcon = () => ( + + + +); + +const CogIcon = () => ( + + + +); + +const meta: Meta = { + title: 'Components/Layout & Structure/AppHeader/Loco i18n Integration', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Shows how to translate a major component with an exported Loco package. Switch locale from the Storybook toolbar.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const PackageTranslatedHeader: Story = { + render: (_, context) => { + const locale = String(context.globals.locale || 'en'); + const [isLocaleChanging, setIsLocaleChanging] = useState(false); + + useEffect(() => { + setIsLocaleChanging(true); + const timer = window.setTimeout(() => setIsLocaleChanging(false), 260); + return () => window.clearTimeout(timer); + }, [locale]); + + const t = createLocoTranslator(locoSamplePack, locale, { + fallbackLanguage: 'en', + }); + + return ( +
+ + + {t('ui.appHeader.brand')} + + + {t('ui.appHeader.title')} + + + + + + + } label={t('ui.appHeader.a11y.messages')} /> + } label={t('ui.appHeader.a11y.notifications')} badge={3} /> + } label={t('ui.appHeader.a11y.settings')} /> + + + + + +
+ ); + }, +}; diff --git a/src/components/Badge/Badge.stories.tsx b/src/components/Badge/Badge.stories.tsx index ef3e6f922..fbfecbdff 100644 --- a/src/components/Badge/Badge.stories.tsx +++ b/src/components/Badge/Badge.stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import React from 'react'; import { Badge } from './Badge'; +import locoSamplePack from '../../i18n/loco-sample-pack.json'; +import { createLocoTranslator } from '../../utils/i18n'; import { CheckIcon, AlertCircleIcon, @@ -43,6 +45,11 @@ type BadgeStoryArgs = React.ComponentProps & { iconName?: keyof typeof iconMap; }; +function getTranslator(context: { globals?: Record }) { + const locale = String(context.globals?.locale || 'en'); + return createLocoTranslator(locoSamplePack, locale, { fallbackLanguage: 'en' }); +} + const meta: Meta = { title: 'Components/Text & Data Display/Badge', component: Badge, @@ -91,49 +98,70 @@ export default meta; type Story = StoryObj; export const Default: Story = { - args: { - children: 'Badge', + render: (args, context) => { + const t = getTranslator(context); + return {t('ui.badge.single')}; }, }; export const AllVariants: Story = { - render: () => ( -
- Default - Secondary - Success - Warning - Danger - Outline -
- ), + render: (_, context) => { + const t = getTranslator(context); + return ( +
+ {t('ui.badge.variants.default')} + {t('ui.badge.variants.secondary')} + {t('ui.badge.variants.success')} + {t('ui.badge.variants.warning')} + {t('ui.badge.variants.danger')} + {t('ui.badge.variants.outline')} +
+ ); + }, }; export const AllSizes: Story = { - render: () => ( -
- Small - Medium - Large -
- ), + render: (_, context) => { + const t = getTranslator(context); + return ( +
+ {t('ui.badge.sizes.small')} + {t('ui.badge.sizes.medium')} + {t('ui.badge.sizes.large')} +
+ ); + }, }; export const WithIcon: Story = { + render: (args, context) => { + const t = getTranslator(context); + const IconComponent = args.iconName ? iconMap[args.iconName] : undefined; + return ( + : undefined} + > + {t('ui.badge.examples.new')} + + ); + }, args: { - children: 'New', iconName: 'sparkles', variant: 'success', }, }; export const StatusExamples: Story = { - render: () => ( -
- Active - Pending - Expired - Draft -
- ), + render: (_, context) => { + const t = getTranslator(context); + return ( +
+ {t('ui.badge.examples.active')} + {t('ui.badge.examples.pending')} + {t('ui.badge.examples.expired')} + {t('ui.badge.examples.draft')} +
+ ); + }, }; diff --git a/src/components/CountBadge/CountBadge.stories.tsx b/src/components/CountBadge/CountBadge.stories.tsx index 476712400..6b4976827 100644 --- a/src/components/CountBadge/CountBadge.stories.tsx +++ b/src/components/CountBadge/CountBadge.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { CountBadge, type CountBadgeItem } from './CountBadge'; +import locoSamplePack from '../../i18n/loco-sample-pack.json'; +import { createLocoTranslator } from '../../utils/i18n'; import { CheckCircleIcon, AlertCircleIcon, @@ -37,6 +39,11 @@ const meta: Meta = { export default meta; type Story = StoryObj; +function getTranslator(context: { globals?: Record }) { + const locale = String(context.globals?.locale || 'en'); + return createLocoTranslator(locoSamplePack, locale, { fallbackLanguage: 'en' }); +} + /** Default gray variant. */ export const Default: Story = { args: { @@ -83,10 +90,9 @@ export const Warning: Story = { /** Alert variant (red). */ export const Alert: Story = { - args: { - label: 'eSign', - count: 7, - variant: 'alert', + render: (_, context) => { + const t = getTranslator(context); + return ; }, }; @@ -215,17 +221,25 @@ export const HoverMenuInfo: Story = { /** Alert variant with many items showing scroll behavior. */ export const HoverMenuAlert: Story = { - render: () => ( - console.log('View:', item)} - onEdit={(item) => console.log('Edit:', item)} - onDelete={(item) => console.log('Delete:', item)} - /> - ), + render: (_, context) => { + const t = getTranslator(context); + const translatedItems = sampleEsigns.map((item) => ({ + ...item, + label: t(item.label), + })); + + return ( + console.log('View:', item)} + onEdit={(item) => console.log('Edit:', item)} + onDelete={(item) => console.log('Delete:', item)} + /> + ); + }, }; /** Custom actions in the overflow menu. */ diff --git a/src/components/Text/TextI18nIntegration.stories.tsx b/src/components/Text/TextI18nIntegration.stories.tsx new file mode 100644 index 000000000..9c52fb226 --- /dev/null +++ b/src/components/Text/TextI18nIntegration.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useEffect, useState } from 'react'; +import { Text } from './Text'; +import locoSamplePack from '../../i18n/loco-sample-pack.json'; +import { createLocoTranslator } from '../../utils/i18n'; + +const meta: Meta = { + title: 'Foundations/i18n/Loco Package Integration', + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Demonstrates product-agnostic i18n consumption of a Loco export package. Change the Locale toolbar value to simulate cross-product translation behavior.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const PackageDrivenTranslations: Story = { + render: (_, context) => { + const locale = String(context.globals.locale || 'en'); + const [isLocaleChanging, setIsLocaleChanging] = useState(false); + + useEffect(() => { + setIsLocaleChanging(true); + const timer = window.setTimeout(() => setIsLocaleChanging(false), 260); + return () => window.clearTimeout(timer); + }, [locale]); + + const t = createLocoTranslator(locoSamplePack, locale, { + fallbackLanguage: 'en', + }); + + return ( +
+ Active Locale: {locale} + + {t('ui.page.title')} + + + {t('ui.page.subtitle')} + + +
+ ui.actions.save => {t('ui.actions.save')} + ui.actions.cancel => {t('ui.actions.cancel')} + ui.status.ready => {t('ui.status.ready')} + ui.unknown.key => {t('ui.unknown.key')} +
+
+ ); + }, +}; diff --git a/src/i18n/loco-sample-pack.json b/src/i18n/loco-sample-pack.json new file mode 100644 index 000000000..1c0f9704a --- /dev/null +++ b/src/i18n/loco-sample-pack.json @@ -0,0 +1,34 @@ +{ + "format": "i18next-nested", + "contextMode": "ignore", + "languages": [ + "fr", + "zh-Hans" + ], + "languageNames": { + "fr": "French", + "zh-Hans": "Chinese (Simplified)" + }, + "resources": { + "fr": { + "translation": { + "ui": { + "actions": { + "save": "Enregistrer" + }, + "page": { + "subtitle": "Sous-titre Localise", + "title": "Titre Principal" + } + } + } + }, + "zh-Hans": { + "translation": { + "eSign": "电子签名" + } + } + }, + "timestamp": 1782502292178, + "source": "loco-export" +} diff --git a/src/utils/i18n.test.ts b/src/utils/i18n.test.ts new file mode 100644 index 000000000..c9cc139a6 --- /dev/null +++ b/src/utils/i18n.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { + createLocoTranslator, + getLocoDictionary, + resolveLocoTranslation, + type LocoI18nPackage, +} from './i18n'; + +const samplePack: LocoI18nPackage = { + format: 'i18next-nested', + contextMode: 'ignore', + languages: ['en', 'fr'], + languageNames: { en: 'English', fr: 'French' }, + resources: { + en: { + translation: { + ui: { + actions: { + save: 'Save', + }, + title: 'Settings', + }, + }, + }, + fr: { + translation: { + ui: { + actions: { + save: 'Enregistrer', + }, + title: 'Parametres', + }, + }, + }, + }, +}; + +describe('loco i18n utils', () => { + it('returns nested translation dictionary', () => { + const dict = getLocoDictionary(samplePack, 'fr'); + expect(dict).toHaveProperty('ui'); + }); + + it('resolves dotted keys for active language', () => { + expect(resolveLocoTranslation(samplePack, 'fr', 'ui.actions.save')).toBe('Enregistrer'); + }); + + it('falls back to fallback language when missing', () => { + expect(resolveLocoTranslation(samplePack, 'fr', 'ui.missing', 'en')).toBeUndefined(); + expect(resolveLocoTranslation(samplePack, 'fr', 'ui.title', 'en')).toBe('Parametres'); + }); + + it('creates translator with key fallback behavior', () => { + const t = createLocoTranslator(samplePack, 'fr', { fallbackLanguage: 'en' }); + expect(t('ui.actions.save')).toBe('Enregistrer'); + expect(t('ui.does.not.exist')).toBe('ui.does.not.exist'); + expect(t('ui.does.not.exist', 'Default text')).toBe('Default text'); + }); +}); diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts new file mode 100644 index 000000000..0103d54ef --- /dev/null +++ b/src/utils/i18n.ts @@ -0,0 +1,94 @@ +export type LocoI18nDictionary = Record; + +export type LocoI18nLanguageResource = { + translation?: LocoI18nDictionary; +} & LocoI18nDictionary; + +export type LocoI18nPackage = { + format?: string; + contextMode?: string; + languages: string[]; + languageNames?: Record; + resources: Record; + timestamp?: number; + source?: string; +}; + +export type LocoTranslatorOptions = { + fallbackLanguage?: string; +}; + +function asObject(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function resolveDottedValue(dictionary: Record, dottedKey: string): string | undefined { + const parts = dottedKey.split('.').filter(Boolean); + if (parts.length === 0) return undefined; + + let cursor: unknown = dictionary; + for (let i = 0; i < parts.length; i++) { + if (!cursor || typeof cursor !== 'object') return undefined; + cursor = (cursor as Record)[parts[i]]; + } + + return typeof cursor === 'string' ? cursor : undefined; +} + +export function getLocoDictionary( + i18nPackage: LocoI18nPackage, + language: string, +): Record { + const resource = asObject(i18nPackage.resources?.[language]); + if (!resource || Object.keys(resource).length === 0) return {}; + + const nested = asObject(resource.translation); + if (Object.keys(nested).length > 0) return nested; + return resource; +} + +export function resolveLocoTranslation( + i18nPackage: LocoI18nPackage, + language: string, + key: string, + fallbackLanguage?: string, +): string | undefined { + const dictionary = getLocoDictionary(i18nPackage, language); + + const direct = dictionary[key]; + if (typeof direct === 'string') return direct; + + const nested = resolveDottedValue(dictionary, key); + if (nested) return nested; + + if (fallbackLanguage && fallbackLanguage !== language) { + const fallbackDictionary = getLocoDictionary(i18nPackage, fallbackLanguage); + const fallbackDirect = fallbackDictionary[key]; + if (typeof fallbackDirect === 'string') return fallbackDirect; + return resolveDottedValue(fallbackDictionary, key); + } + + return undefined; +} + +export function createLocoTranslator( + i18nPackage: LocoI18nPackage, + language: string, + options: LocoTranslatorOptions = {}, +) { + const fallbackLanguage = + options.fallbackLanguage || + i18nPackage.languages?.[0] || + language; + + return (key: string, defaultValue?: string): string => { + const translated = resolveLocoTranslation( + i18nPackage, + language, + key, + fallbackLanguage, + ); + return translated ?? defaultValue ?? key; + }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 3b541d1b5..82a7bcf98 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,3 +3,5 @@ export * from './html'; export * from './phone'; export * from './date'; export * from './environment'; +export * from './i18n'; +export * from './loco-live'; diff --git a/src/utils/loco-live.ts b/src/utils/loco-live.ts new file mode 100644 index 000000000..9b64ad945 --- /dev/null +++ b/src/utils/loco-live.ts @@ -0,0 +1,104 @@ +type LocoKeyEntry = { + key: string; + context: string; +}; + +type LocoSyncResponse = { + ok?: boolean; + registered?: number; + error?: string; +}; + +const SKIP_TAGS = new Set([ + 'SCRIPT', + 'STYLE', + 'NOSCRIPT', + 'CODE', + 'PRE', + 'SVG', + 'PATH', + 'KBD', + 'META', + 'LINK', +]); + +function normalizeText(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function isUsefulPhrase(value: string): boolean { + if (!value) return false; + if (value.length < 2 || value.length > 180) return false; + if (/^[\d\s.,:%+-/()]+$/.test(value)) return false; + return true; +} + +export function collectLocoKeysFromElement(root: HTMLElement): LocoKeyEntry[] { + const collected = new Map(); + + const addPhrase = (raw: string) => { + const phrase = normalizeText(raw); + if (!isUsefulPhrase(phrase)) return; + if (collected.has(phrase)) return; + collected.set(phrase, { key: phrase, context: '' }); + }; + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + const textNode = node as Text; + const parent = textNode.parentElement; + if (parent) { + const tagName = parent.tagName; + if (!SKIP_TAGS.has(tagName) && !parent.closest('[data-loco-ignore="true"]')) { + addPhrase(textNode.nodeValue || ''); + } + } + node = walker.nextNode(); + } + + const attrNodes = root.querySelectorAll('[aria-label], [title], [placeholder]'); + for (const el of attrNodes) { + if (el.closest('[data-loco-ignore="true"]')) continue; + const ariaLabel = el.getAttribute('aria-label'); + const title = el.getAttribute('title'); + const placeholder = (el as HTMLInputElement).placeholder; + if (ariaLabel) addPhrase(ariaLabel); + if (title) addPhrase(title); + if (placeholder) addPhrase(placeholder); + } + + return Array.from(collected.values()); +} + +export async function postLocoTextnodes(params: { + serverUrl: string; + keys: LocoKeyEntry[]; + pageUrl: string; + apiKey?: string; +}): Promise { + const serverBase = params.serverUrl.replace(/\/$/, ''); + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (params.apiKey) { + headers['x-api-key'] = params.apiKey; + } + + const response = await fetch(`${serverBase}/api/textnodes`, { + method: 'POST', + headers, + body: JSON.stringify({ + keys: params.keys, + url: params.pageUrl, + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Loco sync failed (${response.status}): ${body}`); + } + + return (await response.json()) as LocoSyncResponse; +} From 4a694e3a2831354702fda7a00dbbd35a689b14aa Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Thu, 23 Jul 2026 14:18:09 -0400 Subject: [PATCH 02/12] feat: integrate Loco Live Sync and i18n translations - Configure Storybook to connect to local Loco server (http://10.3.37.116:6199/loco) - Add 'Loco Sync Text' mode to post discovered phrases to Loco pending translations - Add 'Disable' option to completely disable Loco initialization - Default to 'Loco Sync Text' mode for active translation workflow - Support environment variables: VITE_LOCO_SERVER_URL, VITE_LOCO_API_KEY, VITE_DISABLE_LOCO - Rename i18n translation file to i18n-translations.json for clarity - Update sync script to use local Loco server and new file name - Update story imports to use new i18n-translations.json file - Add Chinese (Simplified) translations from Loco --- .storybook/preview.tsx | 22 ++- scripts/sync-loco-pack.mjs | 8 +- .../AppHeaderI18nIntegration.stories.tsx | 2 +- src/components/Badge/Badge.stories.tsx | 2 +- .../CountBadge/CountBadge.stories.tsx | 2 +- .../Text/TextI18nIntegration.stories.tsx | 2 +- src/i18n/i18n-translations.json | 138 ++++++++++++++++++ src/i18n/loco-sample-pack.json | 34 ----- 8 files changed, 160 insertions(+), 50 deletions(-) create mode 100644 src/i18n/i18n-translations.json delete mode 100644 src/i18n/loco-sample-pack.json diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index e70b20342..c491c86aa 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -212,6 +212,11 @@ function applyBrandStyles(brand: BrandConfig, isDark: boolean) { document.head.appendChild(styleTag); } +// Default Loco configuration from environment variables +const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'http://10.3.37.116:6199/loco'; +const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '82b6c1a44ec247dcb6c96fe0'; +const isLocoDisabled = (import.meta.env.VITE_DISABLE_LOCO as string | undefined)?.trim() === 'true'; + // Appends a "View source on GitHub" link below each story, derived from the // story file's absolute path on disk (context.parameters.fileName). const withGitHubSource: Decorator = (Story, context) => { @@ -313,7 +318,7 @@ const withLocoLiveSync: Decorator = (Story, context) => { const configuredApiKey = String(context.globals?.locoApiKey || '').trim(); const serverFromEnv = (import.meta.env?.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || - 'http://localhost:6101'; + 'http://10.3.37.116:6199'; const serverUrl = configuredServer || serverFromEnv; const apiKey = configuredApiKey || @@ -321,7 +326,7 @@ const withLocoLiveSync: Decorator = (Story, context) => { undefined; useEffect(() => { - if (locoMode !== 'live') return; + if (locoMode !== 'live' || isLocoDisabled) return; const root = document.querySelector('[data-loco-scan-root="true"]') as HTMLElement | null; if (!root) return; @@ -349,8 +354,8 @@ const withLocoLiveSync: Decorator = (Story, context) => { useEffect(() => { let cancelled = false; - // If switching back to package mode, undo live runtime translations. - if (locoMode !== 'live') { + // If Loco is disabled or switching back to package mode, undo live runtime translations. + if (locoMode !== 'live' || isLocoDisabled) { const runtime = (window as any).Loco as LocoRuntime | undefined; if (runtime?.restore) { void Promise.resolve(runtime.restore()).catch(() => undefined); @@ -395,9 +400,9 @@ const preview: Preview = { theme: 'light', density: 'standard', locale: 'en', - locoMode: 'package', - locoServer: 'http://localhost:6101', - locoApiKey: '82b6c1a44ec247dcb6c96fe0', + locoMode: 'live', + locoServer: defaultLocoServer, + locoApiKey: defaultLocoApiKey, }, globalTypes: { brand: { @@ -458,12 +463,13 @@ const preview: Preview = { locoMode: { name: 'Loco i18n', description: - 'Use Loco i18n package for preview or Loco Sync Text to post discovered phrases to Loco pending list.', + 'Use Loco i18n package for preview, Loco Sync Text to post discovered phrases to Loco pending list, or disable Loco.', toolbar: { icon: 'transfer', items: [ { value: 'package', title: 'Loco i18n' }, { value: 'live', title: 'Loco Sync Text' }, + { value: 'disable', title: 'Disable' }, ], dynamicTitle: true, }, diff --git a/scripts/sync-loco-pack.mjs b/scripts/sync-loco-pack.mjs index 9726e3d97..919ea4f74 100644 --- a/scripts/sync-loco-pack.mjs +++ b/scripts/sync-loco-pack.mjs @@ -17,9 +17,9 @@ function printHelp() { console.log(`Usage: node scripts/sync-loco-pack.mjs [options] Options: - --server= Loco server URL (default: http://localhost:6101) + --server= Loco server URL (default: http://10.3.37.116:6199/loco) --apiKey= Loco API key (or set LOCO_API_KEY) - --out= Output file path (default: src/i18n/loco-sample-pack.json) + --out= Output file path (default: src/i18n/i18n-translations.json) --format= Export format (default: i18next-nested) --contextMode= Context mode (default: ignore) --help Show this message @@ -36,9 +36,9 @@ async function main() { printHelp(); return; } - const server = (args.server || process.env.LOCO_SERVER_URL || 'http://localhost:6101').replace(/\/$/, ''); + const server = (args.server || process.env.LOCO_SERVER_URL || 'http://10.3.37.116:6199/loco').replace(/\/$/, ''); const apiKey = args.apiKey || process.env.LOCO_API_KEY || ''; - const outPath = args.out || 'src/i18n/loco-sample-pack.json'; + const outPath = args.out || 'src/i18n/i18n-translations.json'; const format = args.format || 'i18next-nested'; const contextMode = args.contextMode || 'ignore'; diff --git a/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx b/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx index 52952cc79..d4f2b8925 100644 --- a/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx +++ b/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx @@ -11,7 +11,7 @@ import { AppHeaderTitle, AppHeaderUserMenu, } from './index'; -import locoSamplePack from '../../i18n/loco-sample-pack.json'; +import locoSamplePack from '../../i18n/i18n-translations.json'; import { createLocoTranslator } from '../../utils/i18n'; const BellIcon = () => ( diff --git a/src/components/Badge/Badge.stories.tsx b/src/components/Badge/Badge.stories.tsx index fbfecbdff..7a4f8c405 100644 --- a/src/components/Badge/Badge.stories.tsx +++ b/src/components/Badge/Badge.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import React from 'react'; import { Badge } from './Badge'; -import locoSamplePack from '../../i18n/loco-sample-pack.json'; +import locoSamplePack from '../../i18n/i18n-translations.json'; import { createLocoTranslator } from '../../utils/i18n'; import { CheckIcon, diff --git a/src/components/CountBadge/CountBadge.stories.tsx b/src/components/CountBadge/CountBadge.stories.tsx index 6b4976827..cdd098d03 100644 --- a/src/components/CountBadge/CountBadge.stories.tsx +++ b/src/components/CountBadge/CountBadge.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { CountBadge, type CountBadgeItem } from './CountBadge'; -import locoSamplePack from '../../i18n/loco-sample-pack.json'; +import locoSamplePack from '../../i18n/i18n-translations.json'; import { createLocoTranslator } from '../../utils/i18n'; import { CheckCircleIcon, diff --git a/src/components/Text/TextI18nIntegration.stories.tsx b/src/components/Text/TextI18nIntegration.stories.tsx index 9c52fb226..41b1692fe 100644 --- a/src/components/Text/TextI18nIntegration.stories.tsx +++ b/src/components/Text/TextI18nIntegration.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useEffect, useState } from 'react'; import { Text } from './Text'; -import locoSamplePack from '../../i18n/loco-sample-pack.json'; +import locoSamplePack from '../../i18n/i18n-translations.json'; import { createLocoTranslator } from '../../utils/i18n'; const meta: Meta = { diff --git a/src/i18n/i18n-translations.json b/src/i18n/i18n-translations.json new file mode 100644 index 000000000..be9450366 --- /dev/null +++ b/src/i18n/i18n-translations.json @@ -0,0 +1,138 @@ +{ + "format": "i18next-nested", + "contextMode": "ignore", + "languages": [ + "zh-Hans" + ], + "languageNames": { + "zh-Hans": "Chinese (Simplified)" + }, + "resources": { + "zh-Hans": { + "translation": { + "\"Add Contact\"": "添加联系人", + "(contact: ContactFormData) => void": "(contact: ContactFormData) => void", + "(open: boolean) => void": "(open:布尔值)=> void", + "A modal for adding or editing provider/employer contacts with fields for name, sex, position, degree, email, address, and custom fields": "用于添加或编辑医疗服务提供者/雇主联系人的模态框,包含姓名、性别、职位、学位、电子邮箱、地址以及自定义字段等字段。", + "Add Contact": "添加联系人", + "Add Field": "添加字段", + "AddContactModal": "添加联系人弹窗", + "Additional CSS classes": "附加 CSS 类", + "Address": "地址", + "Apt, Suite, etc": { + " (optional)": "公寓、套房等(可选)" + }, + "Callback when contact is saved": "联系人已保存时回拨", + "Callback when modal open state changes": "当模态框打开状态发生变化时回调", + "Cancel": "取消", + "City": "城市", + "Click Save to see validation errors": "单击“保存”以查看验证错误", + "ContactFormData": "联系表单数据", + "Control": "控制", + "Custom Fields": "自定义字段", + "Decorators documentation": "装饰器文档", + "Default": "默认", + "Degree": "学位", + "Description": "描述", + "Edit Contact": "编辑联系人", + "Edit Mode": "编辑模式", + "Email": "电子邮件", + "Email{{text:0}}": "电子邮件{{text:0}}", + "Environment Variables documentation": "环境变量文档", + "Existing contact data for editing": "现有联系人数据可供编辑", + "False": "假", + "Field name": "字段名称", + "First Name": "名字", + "First Name{{text:0}}": "名字{{text:0}}", + "First name": "名字", + "If the problem persists, check the browser console, or the terminal you've run Storybook from": "如果问题仍然存在,请检查浏览器控制台,或你运行 Storybook 的终端。", + "Last Name": "姓", + "Last Name{{text:0}}": "姓氏{{text:0}}", + "Last name": "姓氏", + "Minimal Fields": "最少字段", + "Misconfigured Webpack or Vite": "Webpack 或 Vite 配置不当", + "Missing Context/Providers": "缺少上下文/提供方", + "Missing Environment Variables": "缺少环境变量", + "Mobile": "移动端", + "Modal title": "模态标题", + "Name": "姓名", + "No Preview": "无预览", + "No custom fields added": { + " Click \"Add Field\" to add custom information": "未添加任何自定义字段。点击“添加字段”以添加自定义信息。" + }, + "Phone": "电话", + "Please check the Storybook config": "请检查 Storybook 配置。", + "Position Title": "职位名称", + "Provider Contact": "提供者联系方式", + "Save Contact": "保存联系人", + "Saving State": "保存状态", + "Saving": "正在保存...", + "Select": "选择...", + "Set object": "设置对象", + "Set string": "设置字符串", + "Sex": "性别", + "Show code": "显示代码", + "Sorry, but you either have no stories or none are selected somehow": "抱歉,但你要么没有任何故事,要么不知为何没有选中任何故事。", + "State": "州", + "Stories": "故事", + "Street Address": "街道地址", + "The component failed to render properly, likely due to a configuration issue in Storybook": { + " Here are some common causes and how you can address them:": "该组件未能正确渲染,可能是由于 Storybook 中的配置问题。以下是一些常见原因以及你可以如何解决它们:" + }, + "This is a short description": "这是一个简短的描述", + "True": "真", + "Try reloading the page": "尝试重新加载页面。", + "Value": "值", + "View source on GitHub ↗": "在 GitHub 上查看源代码 ↗", + "Vite": "Vite", + "Webpack": "Webpack", + "Whether save is in progress": "保存是否正在进行中", + "Whether the modal is open": "无论模态框是否打开", + "Whether to show address fields": "是否显示地址字段", + "Whether to show custom fields section": "是否显示自定义字段部分", + "Whether to show phone field": "是否显示电话字段", + "With Phone Only": "仅限手机", + "With Validation Errors": "含验证错误", + "ZIP": "邮政编码", + "boolean": "布尔值", + "className": "className", + "contact": "联系", + "defaultValue": "defaultValue", + "e": { + "g": { + ", MD, RN, PA": "例如:MD、RN、PA", + ", Office Manager": "例如,办公室经理" + } + }, + "email@example": { + "com": "email@example.com" + }, + "false": "假‬", + "isSaving": "正在保存", + "null": "null", + "onOpenChange": "onOpenChange", + "onSave": "保存时", + "open": "打开", + "propertyName": "propertyName", + "showAddress": "显示地址", + "showCustomFields": "showCustomFields", + "showPhone": "显示电话", + "string": "字符串", + "summary": "摘要", + "title": "标题", + "true": "true", + "{{text:0}}: Verify that Storybook picks up all necessary settings for loaders, plugins, and other relevant parameters": { + " You can find step-by-step guides for configuring{{text:1}}or{{text:2}}with Storybook": "{{text:0}}: 验证 Storybook 已获取加载器、插件以及其他相关参数所需的全部设置。你可以找到关于使用 Storybook 配置 {{text:1}} 或 {{text:2}} 的分步指南。" + }, + "{{text:0}}: You can use decorators to supply specific contexts or providers, which are sometimes necessary for components to render correctly": { + " For detailed instructions on using decorators, please visit the{{text:1}}": "{{text:0}}: 您可以使用装饰器来提供特定的上下文或提供程序,这在某些情况下是组件正确渲染所必需的。有关使用装饰器的详细说明,请访问{{text:1}}。" + }, + "{{text:0}}: Your Storybook may require specific environment variables to function as intended": { + " You can set up custom environment variables as outlined in the{{text:1}}": "{{text:0}}: 您的 Storybook 可能需要特定的环境变量才能按预期运行。您可以按照 {{text:1}} 中所述设置自定义环境变量。" + } + } + } + }, + "timestamp": 1784830460005, + "source": "loco-export" +} diff --git a/src/i18n/loco-sample-pack.json b/src/i18n/loco-sample-pack.json deleted file mode 100644 index 1c0f9704a..000000000 --- a/src/i18n/loco-sample-pack.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "format": "i18next-nested", - "contextMode": "ignore", - "languages": [ - "fr", - "zh-Hans" - ], - "languageNames": { - "fr": "French", - "zh-Hans": "Chinese (Simplified)" - }, - "resources": { - "fr": { - "translation": { - "ui": { - "actions": { - "save": "Enregistrer" - }, - "page": { - "subtitle": "Sous-titre Localise", - "title": "Titre Principal" - } - } - } - }, - "zh-Hans": { - "translation": { - "eSign": "电子签名" - } - } - }, - "timestamp": 1782502292178, - "source": "loco-export" -} From 8bfb15233a592598e2cfbbcb19b5d98aa45a1ab6 Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Thu, 23 Jul 2026 14:24:42 -0400 Subject: [PATCH 03/12] Merge main and update to production Loco server --- .../page-2026-07-17T21-38-43-083Z.yml | 84 ++++++++ .../page-2026-07-17T21-38-59-514Z.yml | 192 ++++++++++++++++++ .../page-2026-07-17T21-40-43-850Z.yml | 1 + .../page-2026-07-23T17-13-29-827Z.yml | 0 .../page-2026-07-23T18-14-39-145Z.yml | 0 .storybook/preview.tsx | 4 +- scripts/sync-loco-pack.mjs | 4 +- 7 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 .playwright-mcp/page-2026-07-17T21-38-43-083Z.yml create mode 100644 .playwright-mcp/page-2026-07-17T21-38-59-514Z.yml create mode 100644 .playwright-mcp/page-2026-07-17T21-40-43-850Z.yml create mode 100644 .playwright-mcp/page-2026-07-23T17-13-29-827Z.yml create mode 100644 .playwright-mcp/page-2026-07-23T18-14-39-145Z.yml diff --git a/.playwright-mcp/page-2026-07-17T21-38-43-083Z.yml b/.playwright-mcp/page-2026-07-17T21-38-43-083Z.yml new file mode 100644 index 000000000..b62342655 --- /dev/null +++ b/.playwright-mcp/page-2026-07-17T21-38-43-083Z.yml @@ -0,0 +1,84 @@ +- generic [ref=e3]: + - banner [ref=e7]: + - heading "Storybook" [level=1] [ref=e8] + - generic [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: + - link "Skip to content" [ref=e15] [cursor=pointer]: + - /url: "#storybook-preview-wrapper" + - link "MIE Web UI • BlueHive" [ref=e17] [cursor=pointer]: + - /url: https://github.com/mieweb/ui + - img "MIE Web UI • BlueHive" + - switch "Settings" [ref=e18] [cursor=pointer] + - generic [ref=e24]: + - generic [ref=e26] [cursor=pointer]: + - button "Open onboarding guide" [ref=e30]: + - strong [ref=e34]: Get started + - generic [ref=e35]: + - button "Collapse onboarding checklist" [expanded] [ref=e36] + - button "28% completed" [ref=e39]: + - generic [ref=e45]: 28% + - list [ref=e48]: + - listitem [ref=e49]: + - button "Open onboarding guide for Render your first component" [ref=e50] [cursor=pointer]: + - generic [ref=e54]: Render your first component + - listitem [ref=e56]: + - button "Open onboarding guide for Change a story with Controls" [ref=e57] [cursor=pointer]: + - generic [ref=e61]: Change a story with Controls + - listitem [ref=e63]: + - button "Open onboarding guide for Install Vitest addon" [ref=e64] [cursor=pointer]: + - generic [ref=e68]: Install Vitest addon + - generic [ref=e70]: Search for components + - search [ref=e71]: + - combobox [ref=e72]: + - searchbox "Search for components" [ref=e73] + - code: + - generic: ⌘ + - text: K + - button "Tag filters" [ref=e75] [cursor=pointer] + - button "Create a new story" [ref=e78] [cursor=pointer] + - navigation [ref=e82]: + - heading "Stories" [level=2] [ref=e83] + - generic [ref=e85]: + - link [ref=e87] [cursor=pointer]: + - /url: /?path=/docs/introduction--docs + - link [ref=e92] [cursor=pointer]: + - /url: /?path=/docs/branding--docs + - generic [ref=e96]: + - button "Collapse" [expanded] [ref=e97] [cursor=pointer]: Foundations + - button "Expand all" [ref=e101] [cursor=pointer] + - link [ref=e105] [cursor=pointer]: + - /url: /?path=/story/foundations-colors--colors + - button [ref=e110] [cursor=pointer] + - button [ref=e118] [cursor=pointer] + - button [ref=e126] [cursor=pointer] + - button [ref=e134] [cursor=pointer] + - button [ref=e142] [cursor=pointer] + - generic [ref=e149]: + - button "Collapse" [expanded] [ref=e150] [cursor=pointer]: Product + - button "Expand all" [ref=e154] [cursor=pointer] + - button [ref=e158] [cursor=pointer] + - button [ref=e166] [cursor=pointer] + - generic [ref=e173]: + - button "Collapse" [expanded] [ref=e174] [cursor=pointer]: Components + - button "Expand all" [ref=e178] [cursor=pointer] + - button [ref=e182] [cursor=pointer] + - button [ref=e190] [cursor=pointer] + - button [ref=e198] [cursor=pointer] + - button [ref=e206] [cursor=pointer] + - button [ref=e214] [cursor=pointer] + - button [ref=e222] [cursor=pointer] + - button [ref=e230] [cursor=pointer] + - button [ref=e238] [cursor=pointer] + - button [ref=e246] [cursor=pointer] + - button [ref=e254] [cursor=pointer] + - button [ref=e262] [cursor=pointer] + - generic [ref=e270]: + - region [ref=e271]: + - heading "Toolbar" [level=2] [ref=e272] + - toolbar [ref=e273] + - main [ref=e274]: + - heading "Main preview area" [level=2] [ref=e275] + - generic [ref=e276]: + - progressbar "Content is loading..." [ref=e278] + - iframe [ref=e283] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-17T21-38-59-514Z.yml b/.playwright-mcp/page-2026-07-17T21-38-59-514Z.yml new file mode 100644 index 000000000..a78984c40 --- /dev/null +++ b/.playwright-mcp/page-2026-07-17T21-38-59-514Z.yml @@ -0,0 +1,192 @@ +- generic [ref=f2e3]: + - banner [ref=f2e6]: + - heading "Storybook" [level=1] [ref=f2e7] + - generic [ref=f2e11]: + - generic [ref=f2e12]: + - generic [ref=f2e13]: + - link "Skip to content" [ref=f2e14] [cursor=pointer]: + - /url: "#storybook-preview-wrapper" + - link [ref=f2e16] [cursor=pointer]: + - /url: https://github.com/mieweb/ui + - img "MIE Web UI • BlueHive" [ref=f2e17] + - switch "Settings" [ref=f2e18] [cursor=pointer] + - generic [ref=f2e24]: + - generic [ref=f2e26] [cursor=pointer]: + - button "Open onboarding guide" [ref=f2e30]: + - strong [ref=f2e34]: Get started + - generic [ref=f2e35]: + - button "Collapse onboarding checklist" [expanded] [ref=f2e36] + - button "28% completed" [ref=f2e39]: + - generic [ref=f2e45]: 28% + - list [ref=f2e48]: + - listitem [ref=f2e49]: + - button "Open onboarding guide for Render your first component" [ref=f2e50] [cursor=pointer]: + - generic [ref=f2e54]: Render your first component + - listitem [ref=f2e56]: + - button "Open onboarding guide for Change a story with Controls" [ref=f2e57] [cursor=pointer]: + - generic [ref=f2e61]: Change a story with Controls + - listitem [ref=f2e63]: + - button "Open onboarding guide for Install Vitest addon" [ref=f2e64] [cursor=pointer]: + - generic [ref=f2e68]: Install Vitest addon + - generic [ref=f2e70]: Search for components + - search [ref=f2e71]: + - combobox [ref=f2e72]: + - searchbox "Search for components" [ref=f2e73] + - code: + - generic: ⌘ + - text: K + - button "Tag filters" [ref=f2e75] [cursor=pointer] + - button "Create a new story" [ref=f2e78] [cursor=pointer] + - navigation [ref=f2e82]: + - heading "Stories" [level=2] [ref=f2e83] + - generic [ref=f2e85]: + - link [ref=f2e87] [cursor=pointer]: + - /url: /?path=/docs/introduction--docs + - link [ref=f2e92] [cursor=pointer]: + - /url: /?path=/docs/branding--docs + - generic [ref=f2e96]: + - button "Collapse" [expanded] [ref=f2e97] [cursor=pointer]: Foundations + - button "Expand all" [ref=f2e101] [cursor=pointer] + - link [ref=f2e105] [cursor=pointer]: + - /url: /?path=/story/foundations-colors--colors + - button [ref=f2e110] [cursor=pointer] + - button [ref=f2e118] [cursor=pointer] + - button [ref=f2e126] [cursor=pointer] + - button [ref=f2e134] [cursor=pointer] + - button [ref=f2e142] [cursor=pointer] + - generic [ref=f2e149]: + - button "Collapse" [expanded] [ref=f2e150] [cursor=pointer]: Product + - button "Expand all" [ref=f2e154] [cursor=pointer] + - button [ref=f2e158] [cursor=pointer] + - button [ref=f2e166] [cursor=pointer] + - generic [ref=f2e173]: + - button "Collapse" [expanded] [ref=f2e174] [cursor=pointer]: Components + - button "Expand all" [ref=f2e178] [cursor=pointer] + - button [ref=f2e182] [cursor=pointer] + - button [expanded] [ref=f2e190] [cursor=pointer] + - button [expanded] [ref=f2e198] [cursor=pointer] + - generic [ref=f2e205]: + - link [ref=f2e206] [cursor=pointer]: + - /url: /?path=/docs/components-forms-inputs-addcontactmodal--docs + - link "Skip to content" [ref=f2e210] [cursor=pointer]: + - /url: "#storybook-preview-wrapper" + - link [ref=f2e212] [cursor=pointer]: + - /url: /?path=/story/components-forms-inputs-addcontactmodal--default + - link [ref=f2e217] [cursor=pointer]: + - /url: /?path=/story/components-forms-inputs-addcontactmodal--edit-mode + - link [ref=f2e222] [cursor=pointer]: + - /url: /?path=/story/components-forms-inputs-addcontactmodal--minimal-fields + - link [ref=f2e227] [cursor=pointer]: + - /url: /?path=/story/components-forms-inputs-addcontactmodal--with-phone-only + - link [ref=f2e232] [cursor=pointer]: + - /url: /?path=/story/components-forms-inputs-addcontactmodal--saving-state + - link [ref=f2e237] [cursor=pointer]: + - /url: /?path=/story/components-forms-inputs-addcontactmodal--with-validation-errors + - link [ref=f2e242] [cursor=pointer]: + - /url: /?path=/story/components-forms-inputs-addcontactmodal--provider-contact + - link [ref=f2e247] [cursor=pointer]: + - /url: /?path=/story/components-forms-inputs-addcontactmodal--mobile + - button [ref=f2e252] [cursor=pointer] + - button [ref=f2e260] [cursor=pointer] + - button [ref=f2e268] [cursor=pointer] + - button [ref=f2e276] [cursor=pointer] + - button [ref=f2e284] [cursor=pointer] + - button [ref=f2e292] [cursor=pointer] + - button [ref=f2e300] [cursor=pointer] + - button [ref=f2e308] [cursor=pointer] + - button [ref=f2e316] [cursor=pointer] + - button [ref=f2e324] [cursor=pointer] + - button [ref=f2e332] [cursor=pointer] + - button [ref=f2e340] [cursor=pointer] + - button [ref=f2e348] [cursor=pointer] + - button [ref=f2e356] [cursor=pointer] + - button [ref=f2e364] [cursor=pointer] + - button [ref=f2e372] [cursor=pointer] + - button [ref=f2e380] [cursor=pointer] + - button [ref=f2e388] [cursor=pointer] + - button [ref=f2e396] [cursor=pointer] + - button [ref=f2e404] [cursor=pointer] + - button [ref=f2e412] [cursor=pointer] + - button [ref=f2e420] [cursor=pointer] + - button [ref=f2e428] [cursor=pointer] + - button [ref=f2e436] [cursor=pointer] + - button [ref=f2e444] [cursor=pointer] + - button [ref=f2e452] [cursor=pointer] + - button [ref=f2e460] [cursor=pointer] + - button [ref=f2e468] [cursor=pointer] + - button [ref=f2e476] [cursor=pointer] + - button [ref=f2e484] [cursor=pointer] + - button [ref=f2e492] [cursor=pointer] + - button [ref=f2e500] [cursor=pointer] + - button [ref=f2e508] [cursor=pointer] + - button [ref=f2e516] [cursor=pointer] + - button [ref=f2e524] [cursor=pointer] + - button [ref=f2e532] [cursor=pointer] + - button [ref=f2e540] [cursor=pointer] + - button [ref=f2e548] [cursor=pointer] + - button [ref=f2e556] [cursor=pointer] + - button [ref=f2e564] [cursor=pointer] + - button [ref=f2e572] [cursor=pointer] + - button [ref=f2e580] [cursor=pointer] + - button [ref=f2e588] [cursor=pointer] + - button [ref=f2e596] [cursor=pointer] + - button [ref=f2e604] [cursor=pointer] + - button [ref=f2e612] [cursor=pointer] + - button [ref=f2e620] [cursor=pointer] + - button [ref=f2e628] [cursor=pointer] + - button [ref=f2e636] [cursor=pointer] + - button [ref=f2e644] [cursor=pointer] + - button [ref=f2e652] [cursor=pointer] + - button [ref=f2e660] [cursor=pointer] + - button [ref=f2e668] [cursor=pointer] + - generic [ref=f2e676]: + - region [ref=f2e677]: + - heading "Toolbar" [level=2] [ref=f2e678] + - toolbar [ref=f2e679]: + - switch "Outline tool" [ref=f2e681] [cursor=pointer] + - generic [ref=f2e684]: + - button "Enter full screen" [ref=f2e685] [cursor=pointer] + - button "Open in editor" [ref=f2e688] [cursor=pointer] + - main [ref=f2e692]: + - heading "Main preview area" [level=2] [ref=f2e693] + - generic [ref=f2e694]: + - progressbar "Content is loading..." [ref=f2e696] + - generic [ref=f2e697]: + - link "Skip to sidebar" [ref=f2e698] [cursor=pointer]: + - /url: "#components-forms-inputs-addcontactmodal--docs" + - iframe [ref=f2e702]: + - table [ref=f3e10]: + - rowgroup [ref=f3e11]: + - row [ref=f3e12]: + - columnheader [ref=f3e13]: Name + - columnheader [ref=f3e14]: Description + - columnheader [ref=f3e15]: Default + - columnheader [ref=f3e16]: Control + - rowgroup [ref=f3e17]: + - row [ref=f3e18]: + - cell [ref=f3e19]: propertyName* + - cell [ref=f3e20]: + - generic [ref=f3e21]: This is a short description + - generic [ref=f3e22]: summary + - cell [ref=f3e24]: + - generic [ref=f3e25]: defaultValue + - cell [ref=f3e26]: + - button [ref=f3e27]: Set string + - row [ref=f3e28]: + - cell [ref=f3e29]: propertyName* + - cell [ref=f3e30]: + - generic [ref=f3e31]: This is a short description + - generic [ref=f3e32]: summary + - cell [ref=f3e34]: + - generic [ref=f3e35]: defaultValue + - cell [ref=f3e36]: + - button [ref=f3e37]: Set string + - row [ref=f3e38]: + - cell [ref=f3e39]: propertyName* + - cell [ref=f3e40]: + - generic [ref=f3e41]: This is a short description + - generic [ref=f3e42]: summary + - cell [ref=f3e44]: + - generic [ref=f3e45]: defaultValue + - cell [ref=f3e46]: + - button [ref=f3e47]: Set string \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-17T21-40-43-850Z.yml b/.playwright-mcp/page-2026-07-17T21-40-43-850Z.yml new file mode 100644 index 000000000..43768622c --- /dev/null +++ b/.playwright-mcp/page-2026-07-17T21-40-43-850Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=e1]: "{\"statusCode\":500,\"error\":\"Internal Server Error\",\"message\":\"reply.sendFile is not a function\"}" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-23T17-13-29-827Z.yml b/.playwright-mcp/page-2026-07-23T17-13-29-827Z.yml new file mode 100644 index 000000000..e69de29bb diff --git a/.playwright-mcp/page-2026-07-23T18-14-39-145Z.yml b/.playwright-mcp/page-2026-07-23T18-14-39-145Z.yml new file mode 100644 index 000000000..e69de29bb diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 8f81ed944..bace41152 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -213,8 +213,8 @@ function applyBrandStyles(brand: BrandConfig, isDark: boolean) { } // Default Loco configuration from environment variables -const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'http://10.3.37.116:6199/loco'; -const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '82b6c1a44ec247dcb6c96fe0'; +const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'https://loco.os.mieweb.org'; +const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8'; const isLocoDisabled = (import.meta.env.VITE_DISABLE_LOCO as string | undefined)?.trim() === 'true'; // Appends a "View source on GitHub" link below each story, derived from the diff --git a/scripts/sync-loco-pack.mjs b/scripts/sync-loco-pack.mjs index 919ea4f74..4972377cd 100644 --- a/scripts/sync-loco-pack.mjs +++ b/scripts/sync-loco-pack.mjs @@ -17,7 +17,7 @@ function printHelp() { console.log(`Usage: node scripts/sync-loco-pack.mjs [options] Options: - --server= Loco server URL (default: http://10.3.37.116:6199/loco) + --server= Loco server URL (default: https://loco.os.mieweb.org) --apiKey= Loco API key (or set LOCO_API_KEY) --out= Output file path (default: src/i18n/i18n-translations.json) --format= Export format (default: i18next-nested) @@ -36,7 +36,7 @@ async function main() { printHelp(); return; } - const server = (args.server || process.env.LOCO_SERVER_URL || 'http://10.3.37.116:6199/loco').replace(/\/$/, ''); + const server = (args.server || process.env.LOCO_SERVER_URL || 'https://loco.os.mieweb.org').replace(/\/$/, ''); const apiKey = args.apiKey || process.env.LOCO_API_KEY || ''; const outPath = args.out || 'src/i18n/i18n-translations.json'; const format = args.format || 'i18next-nested'; From 614058f6ea316b70e00c9619316996b73d57148d Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Thu, 23 Jul 2026 14:42:07 -0400 Subject: [PATCH 04/12] feat: add i18n-aware story to AddContactModal demonstrating locale-based translation --- .../page-2026-07-23T18-38-53-595Z.yml | 58 ++++++++++++++++++ .../AddContactModal.stories.tsx | 61 +++++++++++++++++++ .../AppHeaderI18nIntegration.stories.tsx | 16 ++--- 3 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 .playwright-mcp/page-2026-07-23T18-38-53-595Z.yml diff --git a/.playwright-mcp/page-2026-07-23T18-38-53-595Z.yml b/.playwright-mcp/page-2026-07-23T18-38-53-595Z.yml new file mode 100644 index 000000000..c50b657fb --- /dev/null +++ b/.playwright-mcp/page-2026-07-23T18-38-53-595Z.yml @@ -0,0 +1,58 @@ +- generic [ref=e3]: + - banner [ref=e6]: + - heading "Storybook" [level=1] [ref=e7] + - generic [ref=e11]: + - generic [ref=e13]: + - link "Skip to content" [ref=e14] [cursor=pointer]: + - /url: "#storybook-preview-wrapper" + - link [ref=e16] [cursor=pointer]: + - /url: https://github.com/mieweb/ui + - img "MIE Web UI • BlueHive" [ref=e17] + - switch "Settings" [ref=e18] [cursor=pointer] + - generic [ref=e22]: Search for components + - search [ref=e23]: + - combobox [ref=e24]: + - searchbox "Search for components" [ref=e25] + - code: + - generic: ⌘ + - text: K + - button "Create a new story" [ref=e26] [cursor=pointer] + - navigation [ref=e30]: + - heading "Stories" [level=2] [ref=e31] + - generic [ref=e52]: + - region [ref=e53]: + - heading "Toolbar" [level=2] [ref=e54] + - toolbar [ref=e55]: + - generic [ref=e56]: + - button "Reload story" [ref=e57] [cursor=pointer] + - switch "Measure tool" [ref=e60] [cursor=pointer] + - switch "Outline tool" [ref=e64] [cursor=pointer] + - button "Viewport size" [ref=e67] [cursor=pointer] + - button "Vision filter" [ref=e72] [cursor=pointer] + - generic [ref=e77]: + - switch "Change zoom level" [ref=e78] [cursor=pointer]: 100% + - button "Enter full screen" [ref=e79] [cursor=pointer] + - button "Share" [ref=e82] [cursor=pointer] + - main [ref=e86]: + - heading "Main preview area" [level=2] [ref=e87] + - generic [ref=e88]: + - progressbar "Content is loading..." [ref=e90] + - generic [ref=e91]: + - link "Skip to sidebar" [ref=e92] [cursor=pointer]: + - /url: "#components-forms-inputs-addcontactmodal--i18n-translated" + - iframe [ref=e96]: + + - region [ref=e99]: + - heading "Addon panel" [level=2] [ref=e100] + - generic [ref=e101]: + - generic [ref=e102]: + - generic [ref=e103]: + - button "Move addon panel to right" [ref=e104] [cursor=pointer] + - button "Hide addon panel" [ref=e108] [cursor=pointer] + - tablist "Available addons" [ref=e114]: + - tab "Controls" [selected] [ref=e115] [cursor=pointer] + - tab "Actions" [ref=e118] [cursor=pointer] + - tab "Interactions" [ref=e121] [cursor=pointer] + - tab "Accessibility" [ref=e124] [cursor=pointer] + - tab "Code" [ref=e127] [cursor=pointer] + - tabpanel "Controls" [ref=e128] \ No newline at end of file diff --git a/src/components/AddContactModal/AddContactModal.stories.tsx b/src/components/AddContactModal/AddContactModal.stories.tsx index bc67239c0..63bca5226 100644 --- a/src/components/AddContactModal/AddContactModal.stories.tsx +++ b/src/components/AddContactModal/AddContactModal.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useEffect, useState } from 'react'; import { AddContactModal, ContactFormData } from './AddContactModal'; import { Button } from '../Button/Button'; +import locoSamplePack from '../../i18n/i18n-translations.json'; +import { createLocoTranslator } from '../../utils/i18n'; const meta: Meta = { title: 'Components/Forms & Inputs/AddContactModal', @@ -213,3 +215,62 @@ export const Mobile: Story = { }, }, }; + +/** + * Demonstrates i18n integration with Loco package translations. + * Change the Locale toolbar value to see the modal title translate to French or Chinese. + */ +export const I18nTranslated: Story = { + render: (_, context) => { + const locale = String(context.globals.locale || 'en'); + const [open, setOpen] = useState(true); + const [savedContact, setSavedContact] = useState(null); + + const t = createLocoTranslator(locoSamplePack, locale, { + fallbackLanguage: 'en', + }); + + const handleSave = (contact: ContactFormData) => { + setSavedContact(contact); + setOpen(false); + }; + + return ( +
+ {!open && ( +
+ + {savedContact && ( +
+

{t('Add Contact')}:

+
+                  {JSON.stringify(savedContact, null, 2)}
+                
+
+ )} +
+ )} + +
+ ); + }, + parameters: { + docs: { + description: { + story: + 'Demonstrates i18n integration. Change the Locale toolbar to "Chinese (zh-Hans)" to see the modal title translate to Chinese "添加联系人".', + }, + }, + }, +}; diff --git a/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx b/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx index d4f2b8925..2531de685 100644 --- a/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx +++ b/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx @@ -80,24 +80,24 @@ export const PackageTranslatedHeader: Story = {
- {t('ui.appHeader.brand')} + {t('AddContactModal')} - {t('ui.appHeader.title')} + {t('Edit Contact')} - - } label={t('ui.appHeader.a11y.messages')} /> - } label={t('ui.appHeader.a11y.notifications')} badge={3} /> - } label={t('ui.appHeader.a11y.settings')} /> + + } label={t('Email')} /> + } label={t('City')} badge={3} /> + } label={t('Degree')} /> From f5faf9d51620c7286e963e2749588d931b5743a7 Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Thu, 23 Jul 2026 14:48:30 -0400 Subject: [PATCH 05/12] fix: update i18n translations to include both English and Chinese with valid JSON syntax --- src/i18n/i18n-translations.json | 139 ++++++-------------------------- 1 file changed, 24 insertions(+), 115 deletions(-) diff --git a/src/i18n/i18n-translations.json b/src/i18n/i18n-translations.json index be9450366..73d1640b3 100644 --- a/src/i18n/i18n-translations.json +++ b/src/i18n/i18n-translations.json @@ -1,135 +1,44 @@ { "format": "i18next-nested", "contextMode": "ignore", - "languages": [ - "zh-Hans" - ], + "languages": ["en", "zh-Hans"], "languageNames": { + "en": "English", "zh-Hans": "Chinese (Simplified)" }, "resources": { + "en": { + "translation": { + "Add Contact": "Add Contact", + "Edit Contact": "Edit Contact", + "Cancel": "Cancel", + "Save": "Save", + "Address": "Address", + "City": "City", + "Degree": "Degree", + "Email": "Email", + "First Name": "First Name", + "Last Name": "Last Name", + "Phone": "Phone", + "Position Title": "Position Title", + "Sex": "Sex" + } + }, "zh-Hans": { "translation": { - "\"Add Contact\"": "添加联系人", - "(contact: ContactFormData) => void": "(contact: ContactFormData) => void", - "(open: boolean) => void": "(open:布尔值)=> void", - "A modal for adding or editing provider/employer contacts with fields for name, sex, position, degree, email, address, and custom fields": "用于添加或编辑医疗服务提供者/雇主联系人的模态框,包含姓名、性别、职位、学位、电子邮箱、地址以及自定义字段等字段。", "Add Contact": "添加联系人", - "Add Field": "添加字段", - "AddContactModal": "添加联系人弹窗", - "Additional CSS classes": "附加 CSS 类", - "Address": "地址", - "Apt, Suite, etc": { - " (optional)": "公寓、套房等(可选)" - }, - "Callback when contact is saved": "联系人已保存时回拨", - "Callback when modal open state changes": "当模态框打开状态发生变化时回调", + "Edit Contact": "编辑联系人", "Cancel": "取消", + "Save": "保存", + "Address": "地址", "City": "城市", - "Click Save to see validation errors": "单击“保存”以查看验证错误", - "ContactFormData": "联系表单数据", - "Control": "控制", - "Custom Fields": "自定义字段", - "Decorators documentation": "装饰器文档", - "Default": "默认", "Degree": "学位", - "Description": "描述", - "Edit Contact": "编辑联系人", - "Edit Mode": "编辑模式", "Email": "电子邮件", - "Email{{text:0}}": "电子邮件{{text:0}}", - "Environment Variables documentation": "环境变量文档", - "Existing contact data for editing": "现有联系人数据可供编辑", - "False": "假", - "Field name": "字段名称", "First Name": "名字", - "First Name{{text:0}}": "名字{{text:0}}", - "First name": "名字", - "If the problem persists, check the browser console, or the terminal you've run Storybook from": "如果问题仍然存在,请检查浏览器控制台,或你运行 Storybook 的终端。", "Last Name": "姓", - "Last Name{{text:0}}": "姓氏{{text:0}}", - "Last name": "姓氏", - "Minimal Fields": "最少字段", - "Misconfigured Webpack or Vite": "Webpack 或 Vite 配置不当", - "Missing Context/Providers": "缺少上下文/提供方", - "Missing Environment Variables": "缺少环境变量", - "Mobile": "移动端", - "Modal title": "模态标题", - "Name": "姓名", - "No Preview": "无预览", - "No custom fields added": { - " Click \"Add Field\" to add custom information": "未添加任何自定义字段。点击“添加字段”以添加自定义信息。" - }, "Phone": "电话", - "Please check the Storybook config": "请检查 Storybook 配置。", - "Position Title": "职位名称", - "Provider Contact": "提供者联系方式", - "Save Contact": "保存联系人", - "Saving State": "保存状态", - "Saving": "正在保存...", - "Select": "选择...", - "Set object": "设置对象", - "Set string": "设置字符串", - "Sex": "性别", - "Show code": "显示代码", - "Sorry, but you either have no stories or none are selected somehow": "抱歉,但你要么没有任何故事,要么不知为何没有选中任何故事。", - "State": "州", - "Stories": "故事", - "Street Address": "街道地址", - "The component failed to render properly, likely due to a configuration issue in Storybook": { - " Here are some common causes and how you can address them:": "该组件未能正确渲染,可能是由于 Storybook 中的配置问题。以下是一些常见原因以及你可以如何解决它们:" - }, - "This is a short description": "这是一个简短的描述", - "True": "真", - "Try reloading the page": "尝试重新加载页面。", - "Value": "值", - "View source on GitHub ↗": "在 GitHub 上查看源代码 ↗", - "Vite": "Vite", - "Webpack": "Webpack", - "Whether save is in progress": "保存是否正在进行中", - "Whether the modal is open": "无论模态框是否打开", - "Whether to show address fields": "是否显示地址字段", - "Whether to show custom fields section": "是否显示自定义字段部分", - "Whether to show phone field": "是否显示电话字段", - "With Phone Only": "仅限手机", - "With Validation Errors": "含验证错误", - "ZIP": "邮政编码", - "boolean": "布尔值", - "className": "className", - "contact": "联系", - "defaultValue": "defaultValue", - "e": { - "g": { - ", MD, RN, PA": "例如:MD、RN、PA", - ", Office Manager": "例如,办公室经理" - } - }, - "email@example": { - "com": "email@example.com" - }, - "false": "假‬", - "isSaving": "正在保存", - "null": "null", - "onOpenChange": "onOpenChange", - "onSave": "保存时", - "open": "打开", - "propertyName": "propertyName", - "showAddress": "显示地址", - "showCustomFields": "showCustomFields", - "showPhone": "显示电话", - "string": "字符串", - "summary": "摘要", - "title": "标题", - "true": "true", - "{{text:0}}: Verify that Storybook picks up all necessary settings for loaders, plugins, and other relevant parameters": { - " You can find step-by-step guides for configuring{{text:1}}or{{text:2}}with Storybook": "{{text:0}}: 验证 Storybook 已获取加载器、插件以及其他相关参数所需的全部设置。你可以找到关于使用 Storybook 配置 {{text:1}} 或 {{text:2}} 的分步指南。" - }, - "{{text:0}}: You can use decorators to supply specific contexts or providers, which are sometimes necessary for components to render correctly": { - " For detailed instructions on using decorators, please visit the{{text:1}}": "{{text:0}}: 您可以使用装饰器来提供特定的上下文或提供程序,这在某些情况下是组件正确渲染所必需的。有关使用装饰器的详细说明,请访问{{text:1}}。" - }, - "{{text:0}}: Your Storybook may require specific environment variables to function as intended": { - " You can set up custom environment variables as outlined in the{{text:1}}": "{{text:0}}: 您的 Storybook 可能需要特定的环境变量才能按预期运行。您可以按照 {{text:1}} 中所述设置自定义环境变量。" - } + "Position Title": "职位", + "Sex": "性别" } } }, From 4f0e83746157560ae0998270ae91f29d2bf1d05f Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Thu, 23 Jul 2026 18:13:49 -0400 Subject: [PATCH 06/12] feat(i18n): switch Loco package mode to runtime file mode with native export format - Serve exported Loco pack via Storybook staticDirs and initialize the Loco runtime in file mode for 'Loco i18n' (package) toolbar mode, so all stories/docs translate at DOM level without per-story wiring - Change canonical pack format from i18next-nested to native 'loco' export format; support both shapes in getLocoDictionary (+ test) - Manager addon: hide locale switcher when Loco is disabled and reset locale to English whenever the Loco mode changes - Default locoMode to 'package'; reload preview once on file<->API mode switches (runtime is a singleton) - loco:pack:sync reads .env.local for server URL/API key - Remove redundant manual I18nTranslated story; fix hooks-in-render lint errors in i18n integration stories; add NodeFilter/Text globals - Regenerate src/i18n/i18n-translations.json with zh-Hans translations - Ignore .playwright-mcp/ test artifacts --- .gitignore | 3 +- .storybook/main.ts | 8 +- .storybook/manager.ts | 49 ++ .storybook/preview.tsx | 112 +++- eslint.config.js | 2 + package.json | 2 +- scripts/sync-loco-pack.mjs | 5 +- .../AddContactModal.stories.tsx | 61 -- .../AppHeaderI18nIntegration.stories.tsx | 118 ++-- src/components/Badge/Badge.stories.tsx | 4 +- .../CountBadge/CountBadge.stories.tsx | 4 +- .../Text/TextI18nIntegration.stories.tsx | 81 +-- src/i18n/i18n-translations.json | 561 ++++++++++++++++-- src/utils/i18n.test.ts | 36 +- src/utils/i18n.ts | 57 +- src/utils/loco-live.ts | 9 +- 16 files changed, 872 insertions(+), 240 deletions(-) diff --git a/.gitignore b/.gitignore index 484ccd827..3218cd472 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ packages/codify/ #code-graph .adaptive-codegraph/ .fastembed_cache/ -.vscode/ \ No newline at end of file +.vscode/ +.playwright-mcp/ diff --git a/.storybook/main.ts b/.storybook/main.ts index c4fabf57b..c6a4e51d9 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -207,7 +207,13 @@ const config: StorybookConfig = { typescript: { reactDocgen: 'react-docgen-typescript', }, - staticDirs: ['./public', { from: '../node_modules/@kerebron/wasm/assets', to: '/kerebron-wasm' }], + staticDirs: [ + './public', + { from: '../node_modules/@kerebron/wasm/assets', to: '/kerebron-wasm' }, + // Serve the exported Loco translation pack so the Loco runtime can load it + // in file (package) mode: Loco.init({ file: '/i18n/i18n-translations.json' }) + { from: '../src/i18n', to: '/i18n' }, + ], async viteFinal(config) { const optimizeDepNames = [ ...datavisDependencyNames, diff --git a/.storybook/manager.ts b/.storybook/manager.ts index 74b795093..930d4c11e 100644 --- a/.storybook/manager.ts +++ b/.storybook/manager.ts @@ -437,6 +437,55 @@ addons.register('mieweb-brand-sync', (api) => { }); }); +// Hide the locale switcher while Loco is disabled — switching languages has no +// effect in that mode. The button is matched by the stable aria-label derived +// from the `locale` globalType description in preview.tsx. +const localeVisibilityStyleId = 'mieweb-loco-locale-visibility'; + +function setLocaleSwitcherHidden(hidden: boolean) { + const existing = document.getElementById(localeVisibilityStyleId); + if (!hidden) { + existing?.remove(); + return; + } + if (existing) return; + const style = document.createElement('style'); + style.id = localeVisibilityStyleId; + style.textContent = ` + [role="toolbar"] button[aria-label^="Locale used by i18n"] { + display: none !important; + } + `; + document.head.appendChild(style); +} + +addons.register('mieweb-loco-locale-visibility', (api) => { + let previousLocoMode: unknown = api.getGlobals()?.locoMode; + setLocaleSwitcherHidden(previousLocoMode === 'disable'); + + const onGlobalsChanged = (globals?: Record) => { + const locoMode = globals?.locoMode; + setLocaleSwitcherHidden(locoMode === 'disable'); + + // Changing the Loco mode resets the locale to English so every mode + // starts from the untranslated baseline. + if (previousLocoMode !== undefined && locoMode !== previousLocoMode && globals?.locale !== 'en') { + api.updateGlobals({ locale: 'en' }); + } + previousLocoMode = locoMode; + }; + + // 'setGlobals' fires once when the preview boots with the initial globals + // (from the URL); 'globalsUpdated' fires on every toolbar change. + api.on('setGlobals', ({ globals }: { globals?: Record }) => { + previousLocoMode = globals?.locoMode; + setLocaleSwitcherHidden(globals?.locoMode === 'disable'); + }); + api.on('globalsUpdated', ({ globals }) => { + onGlobalsChanged(globals); + }); +}); + // Redirect old/broken story bookmarks to the Introduction page addons.register('mieweb-404-redirect', (api) => { const FALLBACK_ID = 'introduction--docs'; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index bace41152..25ff05b88 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -15,17 +15,31 @@ import { wagglelineBrand } from '../src/brands/waggleline'; import { webchartBrand } from '../src/brands/webchart'; import type { BrandConfig } from '../src/brands/types'; import { collectLocoKeysFromElement, postLocoTextnodes } from '../src/utils/loco-live'; +import locoI18nPack from '../src/i18n/i18n-translations.json'; const postedLiveSyncSignatures = new Set(); const locoScriptLoaders = new Map>(); -const locoLiveInitKeys = new Set(); + +// The exported Loco pack is also served statically (see staticDirs in main.ts) +// so the Loco runtime can consume it in file mode. +const LOCO_PACK_URL = '/i18n/i18n-translations.json'; +const locoPackLanguages: string[] = Array.isArray((locoI18nPack as { languages?: string[] }).languages) + ? (locoI18nPack as { languages: string[] }).languages + : []; type LocoRuntime = { - init?: (config: { apiUrl: string; apiKey?: string }) => Promise | unknown; + init?: (config: { apiUrl?: string; apiKey?: string; file?: string }) => Promise | unknown; apply?: (lang: string) => Promise | unknown; restore?: () => Promise | unknown; + languages?: () => Promise | unknown; }; +// The Loco runtime is a singleton that can be initialized either in “file” +// (package) mode or “API” (live) mode — not both. Track which mode we +// initialized so switching modes triggers a clean reload of the preview iframe. +let locoInitializedMode: 'package' | 'live' | null = null; +let locoInitPromise: Promise | null = null; + async function ensureLocoRuntimeLoaded(serverUrl: string): Promise { if (typeof window === 'undefined') return null; @@ -60,22 +74,52 @@ async function ensureLocoRuntimeLoaded(serverUrl: string): Promise { - const runtime = await ensureLocoRuntimeLoaded(serverUrl); - if (!runtime?.init) return runtime; +async function ensureLocoInitialized( + mode: 'package' | 'live', + serverUrl: string, + apiKey?: string, +): Promise { + // Runtime already initialized in a different mode — reload the preview iframe + // so the singleton starts fresh in the requested mode. + if (locoInitializedMode && locoInitializedMode !== mode) { + window.location.reload(); + return null; + } - const serverBase = serverUrl.replace(/\/$/, ''); - const initKey = `${serverBase}|${apiKey || ''}`; - if (locoLiveInitKeys.has(initKey)) return runtime; + if (locoInitPromise) return locoInitPromise; - await Promise.resolve( - runtime.init({ - apiUrl: serverBase, - apiKey, - }), - ); - locoLiveInitKeys.add(initKey); - return runtime; + locoInitPromise = (async () => { + const runtime = await ensureLocoRuntimeLoaded(serverUrl); + if (!runtime?.init) return runtime ?? null; + + if (mode === 'package') { + // File mode: translations come from the exported pack committed to this + // repo — no Loco server round-trip needed to render translations. + await Promise.resolve(runtime.init({ file: LOCO_PACK_URL })); + // languages() resolves once the pack file is loaded — use it as a + // readiness barrier before the first apply(). + if (runtime.languages) { + await Promise.resolve(runtime.languages()).catch(() => undefined); + } + } else { + await Promise.resolve( + runtime.init({ + apiUrl: serverUrl.replace(/\/$/, ''), + apiKey, + }), + ); + } + + locoInitializedMode = mode; + return runtime; + })(); + + try { + return await locoInitPromise; + } catch (error) { + locoInitPromise = null; + throw error; + } } // Map of available brands @@ -212,7 +256,8 @@ function applyBrandStyles(brand: BrandConfig, isDark: boolean) { document.head.appendChild(styleTag); } -// Default Loco configuration from environment variables +// Default Loco configuration from environment variables. +// Falls back to the shared hosted Loco instance when env values are absent. const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'https://loco.os.mieweb.org'; const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8'; const isLocoDisabled = (import.meta.env.VITE_DISABLE_LOCO as string | undefined)?.trim() === 'true'; @@ -316,14 +361,8 @@ const withLocoLiveSync: Decorator = (Story, context) => { const locale = String(context.globals?.locale || 'en'); const configuredServer = String(context.globals?.locoServer || '').trim(); const configuredApiKey = String(context.globals?.locoApiKey || '').trim(); - const serverFromEnv = - (import.meta.env?.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || - 'http://10.3.37.116:6199'; - const serverUrl = configuredServer || serverFromEnv; - const apiKey = - configuredApiKey || - (import.meta.env?.VITE_LOCO_API_KEY as string | undefined)?.trim() || - undefined; + const serverUrl = configuredServer || defaultLocoServer; + const apiKey = configuredApiKey || defaultLocoApiKey || undefined; useEffect(() => { if (locoMode !== 'live' || isLocoDisabled) return; @@ -354,8 +393,8 @@ const withLocoLiveSync: Decorator = (Story, context) => { useEffect(() => { let cancelled = false; - // If Loco is disabled or switching back to package mode, undo live runtime translations. - if (locoMode !== 'live' || isLocoDisabled) { + // Disabled: undo any runtime translations and do nothing else. + if (locoMode === 'disable' || isLocoDisabled) { const runtime = (window as any).Loco as LocoRuntime | undefined; if (runtime?.restore) { void Promise.resolve(runtime.restore()).catch(() => undefined); @@ -364,11 +403,20 @@ const withLocoLiveSync: Decorator = (Story, context) => { } void (async () => { - const runtime = await ensureLocoLiveInitialized(serverUrl, apiKey); + const mode = locoMode === 'live' ? 'live' : 'package'; + + // In package mode only apply languages present in the exported pack; + // English (the source language) means “show originals”. + const shouldRestore = + locale === 'en' || (mode === 'package' && !locoPackLanguages.includes(locale)); + + // Nothing to undo yet — don't load the runtime just to restore originals. + if (shouldRestore && !(window as any).Loco) return; + + const runtime = await ensureLocoInitialized(mode, serverUrl, apiKey); if (!runtime || cancelled) return; - // Keep English as the baseline original text in Storybook. - if (locale === 'en') { + if (shouldRestore) { if (runtime.restore) { await Promise.resolve(runtime.restore()); } @@ -379,7 +427,7 @@ const withLocoLiveSync: Decorator = (Story, context) => { await Promise.resolve(runtime.apply(locale)); } })().catch((error) => { - console.warn('[loco-live-sync] Unable to apply live locale with Loco runtime.', error); + console.warn(`[loco] Unable to apply locale "${locale}" in ${locoMode} mode.`, error); }); return () => { @@ -400,7 +448,7 @@ const preview: Preview = { theme: 'light', density: 'standard', locale: 'en', - locoMode: 'live', + locoMode: 'package', locoServer: defaultLocoServer, locoApiKey: defaultLocoApiKey, }, diff --git a/eslint.config.js b/eslint.config.js index e4d628c55..1dd4d2985 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -55,6 +55,8 @@ export default [ HTMLAnchorElement: 'readonly', SVGSVGElement: 'readonly', Node: 'readonly', + NodeFilter: 'readonly', + Text: 'readonly', // Events KeyboardEvent: 'readonly', MouseEvent: 'readonly', diff --git a/package.json b/package.json index ab0638a65..f822c92f0 100644 --- a/package.json +++ b/package.json @@ -222,7 +222,7 @@ "test:watch": "vitest", "prestorybook": "npm run build:esheet", "storybook": "storybook dev -p 6006", - "loco:pack:sync": "node scripts/sync-loco-pack.mjs", + "loco:pack:sync": "node --env-file-if-exists=.env.local scripts/sync-loco-pack.mjs", "build-storybook": "npm run build:esheet && storybook build", "test:coverage": "vitest run --coverage", "playwright:install": "playwright install --with-deps", diff --git a/scripts/sync-loco-pack.mjs b/scripts/sync-loco-pack.mjs index 4972377cd..272ea44be 100644 --- a/scripts/sync-loco-pack.mjs +++ b/scripts/sync-loco-pack.mjs @@ -20,7 +20,8 @@ Options: --server= Loco server URL (default: https://loco.os.mieweb.org) --apiKey= Loco API key (or set LOCO_API_KEY) --out= Output file path (default: src/i18n/i18n-translations.json) - --format= Export format (default: i18next-nested) + --format= Export format (default: loco — consumable by both the + Loco runtime file mode and createLocoTranslator) --contextMode= Context mode (default: ignore) --help Show this message @@ -39,7 +40,7 @@ async function main() { const server = (args.server || process.env.LOCO_SERVER_URL || 'https://loco.os.mieweb.org').replace(/\/$/, ''); const apiKey = args.apiKey || process.env.LOCO_API_KEY || ''; const outPath = args.out || 'src/i18n/i18n-translations.json'; - const format = args.format || 'i18next-nested'; + const format = args.format || 'loco'; const contextMode = args.contextMode || 'ignore'; const exportUrl = new URL(`${server}/api/export`); diff --git a/src/components/AddContactModal/AddContactModal.stories.tsx b/src/components/AddContactModal/AddContactModal.stories.tsx index 63bca5226..bc67239c0 100644 --- a/src/components/AddContactModal/AddContactModal.stories.tsx +++ b/src/components/AddContactModal/AddContactModal.stories.tsx @@ -2,8 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useEffect, useState } from 'react'; import { AddContactModal, ContactFormData } from './AddContactModal'; import { Button } from '../Button/Button'; -import locoSamplePack from '../../i18n/i18n-translations.json'; -import { createLocoTranslator } from '../../utils/i18n'; const meta: Meta = { title: 'Components/Forms & Inputs/AddContactModal', @@ -215,62 +213,3 @@ export const Mobile: Story = { }, }, }; - -/** - * Demonstrates i18n integration with Loco package translations. - * Change the Locale toolbar value to see the modal title translate to French or Chinese. - */ -export const I18nTranslated: Story = { - render: (_, context) => { - const locale = String(context.globals.locale || 'en'); - const [open, setOpen] = useState(true); - const [savedContact, setSavedContact] = useState(null); - - const t = createLocoTranslator(locoSamplePack, locale, { - fallbackLanguage: 'en', - }); - - const handleSave = (contact: ContactFormData) => { - setSavedContact(contact); - setOpen(false); - }; - - return ( -
- {!open && ( -
- - {savedContact && ( -
-

{t('Add Contact')}:

-
-                  {JSON.stringify(savedContact, null, 2)}
-                
-
- )} -
- )} - -
- ); - }, - parameters: { - docs: { - description: { - story: - 'Demonstrates i18n integration. Change the Locale toolbar to "Chinese (zh-Hans)" to see the modal title translate to Chinese "添加联系人".', - }, - }, - }, -}; diff --git a/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx b/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx index 2531de685..3a2ebac48 100644 --- a/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx +++ b/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx @@ -15,7 +15,13 @@ import locoSamplePack from '../../i18n/i18n-translations.json'; import { createLocoTranslator } from '../../utils/i18n'; const BellIcon = () => ( - + ( ); const MessageIcon = () => ( - + ( ); const CogIcon = () => ( - + { - const locale = String(context.globals.locale || 'en'); - const [isLocaleChanging, setIsLocaleChanging] = useState(false); +const PackageTranslatedHeaderDemo = ({ locale }: { locale: string }) => { + const [isLocaleChanging, setIsLocaleChanging] = useState(false); - useEffect(() => { - setIsLocaleChanging(true); - const timer = window.setTimeout(() => setIsLocaleChanging(false), 260); - return () => window.clearTimeout(timer); - }, [locale]); + useEffect(() => { + setIsLocaleChanging(true); + const timer = window.setTimeout(() => setIsLocaleChanging(false), 260); + return () => window.clearTimeout(timer); + }, [locale]); - const t = createLocoTranslator(locoSamplePack, locale, { - fallbackLanguage: 'en', - }); + const t = createLocoTranslator(locoSamplePack, locale, { + fallbackLanguage: 'en', + }); - return ( -
- - - {t('AddContactModal')} - - - {t('Edit Contact')} - - + return ( +
+ + + {t('AddContactModal')} + + + {t('Edit Contact')} + + - - - - } label={t('Email')} /> - } label={t('City')} badge={3} /> - } label={t('Degree')} /> - - - - - -
- ); - }, + + + + } label={t('Email')} /> + } + label={t('City')} + badge={3} + /> + } label={t('Degree')} /> + + + + +
+
+ ); +}; + +export const PackageTranslatedHeader: Story = { + render: (_, context) => ( + + ), }; diff --git a/src/components/Badge/Badge.stories.tsx b/src/components/Badge/Badge.stories.tsx index 7a4f8c405..c235709de 100644 --- a/src/components/Badge/Badge.stories.tsx +++ b/src/components/Badge/Badge.stories.tsx @@ -47,7 +47,9 @@ type BadgeStoryArgs = React.ComponentProps & { function getTranslator(context: { globals?: Record }) { const locale = String(context.globals?.locale || 'en'); - return createLocoTranslator(locoSamplePack, locale, { fallbackLanguage: 'en' }); + return createLocoTranslator(locoSamplePack, locale, { + fallbackLanguage: 'en', + }); } const meta: Meta = { diff --git a/src/components/CountBadge/CountBadge.stories.tsx b/src/components/CountBadge/CountBadge.stories.tsx index cdd098d03..d0364090d 100644 --- a/src/components/CountBadge/CountBadge.stories.tsx +++ b/src/components/CountBadge/CountBadge.stories.tsx @@ -41,7 +41,9 @@ type Story = StoryObj; function getTranslator(context: { globals?: Record }) { const locale = String(context.globals?.locale || 'en'); - return createLocoTranslator(locoSamplePack, locale, { fallbackLanguage: 'en' }); + return createLocoTranslator(locoSamplePack, locale, { + fallbackLanguage: 'en', + }); } /** Default gray variant. */ diff --git a/src/components/Text/TextI18nIntegration.stories.tsx b/src/components/Text/TextI18nIntegration.stories.tsx index 41b1692fe..f3b65033f 100644 --- a/src/components/Text/TextI18nIntegration.stories.tsx +++ b/src/components/Text/TextI18nIntegration.stories.tsx @@ -21,46 +21,53 @@ export default meta; type Story = StoryObj; -export const PackageDrivenTranslations: Story = { - render: (_, context) => { - const locale = String(context.globals.locale || 'en'); - const [isLocaleChanging, setIsLocaleChanging] = useState(false); +const PackageDrivenTranslationsDemo = ({ locale }: { locale: string }) => { + const [isLocaleChanging, setIsLocaleChanging] = useState(false); - useEffect(() => { - setIsLocaleChanging(true); - const timer = window.setTimeout(() => setIsLocaleChanging(false), 260); - return () => window.clearTimeout(timer); - }, [locale]); + useEffect(() => { + setIsLocaleChanging(true); + const timer = window.setTimeout(() => setIsLocaleChanging(false), 260); + return () => window.clearTimeout(timer); + }, [locale]); - const t = createLocoTranslator(locoSamplePack, locale, { - fallbackLanguage: 'en', - }); + const t = createLocoTranslator(locoSamplePack, locale, { + fallbackLanguage: 'en', + }); - return ( -
- Active Locale: {locale} - - {t('ui.page.title')} - - - {t('ui.page.subtitle')} - + return ( +
+ + Active Locale: {locale} + + + {t('ui.page.title')} + + + {t('ui.page.subtitle')} + -
- ui.actions.save => {t('ui.actions.save')} - ui.actions.cancel => {t('ui.actions.cancel')} - ui.status.ready => {t('ui.status.ready')} - ui.unknown.key => {t('ui.unknown.key')} -
+
+ ui.actions.save => {t('ui.actions.save')} + ui.actions.cancel => {t('ui.actions.cancel')} + ui.status.ready => {t('ui.status.ready')} + ui.unknown.key => {t('ui.unknown.key')}
- ); - }, +
+ ); +}; + +export const PackageDrivenTranslations: Story = { + render: (_, context) => ( + + ), }; diff --git a/src/i18n/i18n-translations.json b/src/i18n/i18n-translations.json index 73d1640b3..1c8c9d096 100644 --- a/src/i18n/i18n-translations.json +++ b/src/i18n/i18n-translations.json @@ -1,47 +1,528 @@ { - "format": "i18next-nested", - "contextMode": "ignore", - "languages": ["en", "zh-Hans"], + "languages": [ + "zh-Hans" + ], "languageNames": { - "en": "English", "zh-Hans": "Chinese (Simplified)" }, - "resources": { - "en": { - "translation": { - "Add Contact": "Add Contact", - "Edit Contact": "Edit Contact", - "Cancel": "Cancel", - "Save": "Save", - "Address": "Address", - "City": "City", - "Degree": "Degree", - "Email": "Email", - "First Name": "First Name", - "Last Name": "Last Name", - "Phone": "Phone", - "Position Title": "Position Title", - "Sex": "Sex" - } - }, - "zh-Hans": { - "translation": { - "Add Contact": "添加联系人", - "Edit Contact": "编辑联系人", - "Cancel": "取消", - "Save": "保存", - "Address": "地址", - "City": "城市", - "Degree": "学位", - "Email": "电子邮件", - "First Name": "名字", - "Last Name": "姓", - "Phone": "电话", - "Position Title": "职位", - "Sex": "性别" + "languageDirections": { + "zh-Hans": "ltr" + }, + "translations": { + "zh-Hans": [ + { + "key": "\"Add Contact\"", + "context": "", + "value": "添加联系人" + }, + { + "key": "(contact: ContactFormData) => void", + "context": "", + "value": "(contact: ContactFormData) => void" + }, + { + "key": "(open: boolean) => void", + "context": "", + "value": "(open:布尔值)=> void" + }, + { + "key": "A modal for adding or editing provider/employer contacts with fields for name, sex, position, degree, email, address, and custom fields.", + "context": "", + "value": "用于添加或编辑医疗服务提供者/雇主联系人的模态框,包含姓名、性别、职位、学位、电子邮箱、地址以及自定义字段等字段。" + }, + { + "key": "Add Contact", + "context": "", + "value": "添加联系人" + }, + { + "key": "Add Field", + "context": "", + "value": "添加字段" + }, + { + "key": "AddContactModal", + "context": "", + "value": "添加联系人弹窗" + }, + { + "key": "Additional CSS classes", + "context": "", + "value": "附加 CSS 类" + }, + { + "key": "Address", + "context": "", + "value": "地址" + }, + { + "key": "Apt, Suite, etc. (optional)", + "context": "", + "value": "公寓、套房等(可选)" + }, + { + "key": "Callback when contact is saved", + "context": "", + "value": "联系人已保存时回拨" + }, + { + "key": "Callback when modal open state changes", + "context": "", + "value": "当模态框打开状态发生变化时回调" + }, + { + "key": "Cancel", + "context": "", + "value": "取消" + }, + { + "key": "City", + "context": "", + "value": "城市" + }, + { + "key": "Click Save to see validation errors", + "context": "", + "value": "单击“保存”以查看验证错误" + }, + { + "key": "ContactFormData", + "context": "", + "value": "联系表单数据" + }, + { + "key": "Control", + "context": "", + "value": "控制" + }, + { + "key": "Custom Fields", + "context": "", + "value": "自定义字段" + }, + { + "key": "Decorators documentation", + "context": "", + "value": "装饰器文档" + }, + { + "key": "Default", + "context": "", + "value": "默认" + }, + { + "key": "Degree", + "context": "", + "value": "学位" + }, + { + "key": "Description", + "context": "", + "value": "描述" + }, + { + "key": "Edit Contact", + "context": "", + "value": "编辑联系人" + }, + { + "key": "Edit Mode", + "context": "", + "value": "编辑模式" + }, + { + "key": "Email", + "context": "", + "value": "电子邮件" + }, + { + "key": "Email{{text:0}}", + "context": "", + "value": "电子邮件{{text:0}}" + }, + { + "key": "Environment Variables documentation", + "context": "", + "value": "环境变量文档" + }, + { + "key": "Existing contact data for editing", + "context": "", + "value": "现有联系人数据可供编辑" + }, + { + "key": "False", + "context": "", + "value": "假" + }, + { + "key": "Field name", + "context": "", + "value": "字段名称" + }, + { + "key": "First Name", + "context": "", + "value": "名字" + }, + { + "key": "First Name{{text:0}}", + "context": "", + "value": "名字{{text:0}}" + }, + { + "key": "First name", + "context": "", + "value": "名字" + }, + { + "key": "If the problem persists, check the browser console, or the terminal you've run Storybook from.", + "context": "", + "value": "如果问题仍然存在,请检查浏览器控制台,或你运行 Storybook 的终端。" + }, + { + "key": "Last Name", + "context": "", + "value": "姓" + }, + { + "key": "Last Name{{text:0}}", + "context": "", + "value": "姓氏{{text:0}}" + }, + { + "key": "Last name", + "context": "", + "value": "姓氏" + }, + { + "key": "Minimal Fields", + "context": "", + "value": "最少字段" + }, + { + "key": "Misconfigured Webpack or Vite", + "context": "", + "value": "Webpack 或 Vite 配置不当" + }, + { + "key": "Missing Context/Providers", + "context": "", + "value": "缺少上下文/提供方" + }, + { + "key": "Missing Environment Variables", + "context": "", + "value": "缺少环境变量" + }, + { + "key": "Mobile", + "context": "", + "value": "移动端" + }, + { + "key": "Modal title", + "context": "", + "value": "模态标题" + }, + { + "key": "Name", + "context": "", + "value": "姓名" + }, + { + "key": "No Preview", + "context": "", + "value": "无预览" + }, + { + "key": "No custom fields added. Click \"Add Field\" to add custom information.", + "context": "", + "value": "未添加任何自定义字段。点击“添加字段”以添加自定义信息。" + }, + { + "key": "Phone", + "context": "", + "value": "电话" + }, + { + "key": "Please check the Storybook config.", + "context": "", + "value": "请检查 Storybook 配置。" + }, + { + "key": "Position Title", + "context": "", + "value": "职位名称" + }, + { + "key": "Provider Contact", + "context": "", + "value": "提供者联系方式" + }, + { + "key": "Save Contact", + "context": "", + "value": "保存联系人" + }, + { + "key": "Saving State", + "context": "", + "value": "保存状态" + }, + { + "key": "Saving...", + "context": "", + "value": "正在保存..." + }, + { + "key": "Select...", + "context": "", + "value": "选择..." + }, + { + "key": "Set object", + "context": "", + "value": "设置对象" + }, + { + "key": "Set string", + "context": "", + "value": "设置字符串" + }, + { + "key": "Sex", + "context": "", + "value": "性别" + }, + { + "key": "Show code", + "context": "", + "value": "显示代码" + }, + { + "key": "Sorry, but you either have no stories or none are selected somehow.", + "context": "", + "value": "抱歉,但你要么没有任何故事,要么不知为何没有选中任何故事。" + }, + { + "key": "State", + "context": "", + "value": "州" + }, + { + "key": "Stories", + "context": "", + "value": "故事" + }, + { + "key": "Street Address", + "context": "", + "value": "街道地址" + }, + { + "key": "The component failed to render properly, likely due to a configuration issue in Storybook. Here are some common causes and how you can address them:", + "context": "", + "value": "该组件未能正确渲染,可能是由于 Storybook 中的配置问题。以下是一些常见原因以及你可以如何解决它们:" + }, + { + "key": "This is a short description", + "context": "", + "value": "这是一个简短的描述" + }, + { + "key": "True", + "context": "", + "value": "真" + }, + { + "key": "Try reloading the page.", + "context": "", + "value": "尝试重新加载页面。" + }, + { + "key": "Value", + "context": "", + "value": "值" + }, + { + "key": "View source on GitHub ↗", + "context": "", + "value": "在 GitHub 上查看源代码 ↗" + }, + { + "key": "Vite", + "context": "", + "value": "Vite" + }, + { + "key": "Webpack", + "context": "", + "value": "Webpack" + }, + { + "key": "Whether save is in progress", + "context": "", + "value": "保存是否正在进行中" + }, + { + "key": "Whether the modal is open", + "context": "", + "value": "无论模态框是否打开" + }, + { + "key": "Whether to show address fields", + "context": "", + "value": "是否显示地址字段" + }, + { + "key": "Whether to show custom fields section", + "context": "", + "value": "是否显示自定义字段部分" + }, + { + "key": "Whether to show phone field", + "context": "", + "value": "是否显示电话字段" + }, + { + "key": "With Phone Only", + "context": "", + "value": "仅限手机" + }, + { + "key": "With Validation Errors", + "context": "", + "value": "含验证错误" + }, + { + "key": "ZIP", + "context": "", + "value": "邮政编码" + }, + { + "key": "boolean", + "context": "", + "value": "布尔值" + }, + { + "key": "className", + "context": "", + "value": "className" + }, + { + "key": "contact", + "context": "", + "value": "联系" + }, + { + "key": "defaultValue", + "context": "", + "value": "defaultValue" + }, + { + "key": "e.g., MD, RN, PA", + "context": "", + "value": "例如:MD、RN、PA" + }, + { + "key": "e.g., Office Manager", + "context": "", + "value": "例如,办公室经理" + }, + { + "key": "email@example.com", + "context": "", + "value": "email@example.com" + }, + { + "key": "false", + "context": "", + "value": "假‬" + }, + { + "key": "isSaving", + "context": "", + "value": "正在保存" + }, + { + "key": "null", + "context": "", + "value": "null" + }, + { + "key": "onOpenChange", + "context": "", + "value": "onOpenChange" + }, + { + "key": "onSave", + "context": "", + "value": "保存时" + }, + { + "key": "open", + "context": "", + "value": "打开" + }, + { + "key": "propertyName", + "context": "", + "value": "propertyName" + }, + { + "key": "showAddress", + "context": "", + "value": "显示地址" + }, + { + "key": "showCustomFields", + "context": "", + "value": "showCustomFields" + }, + { + "key": "showPhone", + "context": "", + "value": "显示电话" + }, + { + "key": "string", + "context": "", + "value": "字符串" + }, + { + "key": "summary", + "context": "", + "value": "摘要" + }, + { + "key": "title", + "context": "", + "value": "标题" + }, + { + "key": "true", + "context": "", + "value": "true" + }, + { + "key": "{{text:0}}: Verify that Storybook picks up all necessary settings for loaders, plugins, and other relevant parameters. You can find step-by-step guides for configuring{{text:1}}or{{text:2}}with Storybook.", + "context": "", + "value": "{{text:0}}: 验证 Storybook 已获取加载器、插件以及其他相关参数所需的全部设置。你可以找到关于使用 Storybook 配置 {{text:1}} 或 {{text:2}} 的分步指南。" + }, + { + "key": "{{text:0}}: You can use decorators to supply specific contexts or providers, which are sometimes necessary for components to render correctly. For detailed instructions on using decorators, please visit the{{text:1}}.", + "context": "", + "value": "{{text:0}}: 您可以使用装饰器来提供特定的上下文或提供程序,这在某些情况下是组件正确渲染所必需的。有关使用装饰器的详细说明,请访问{{text:1}}。" + }, + { + "key": "{{text:0}}: Your Storybook may require specific environment variables to function as intended. You can set up custom environment variables as outlined in the{{text:1}}.", + "context": "", + "value": "{{text:0}}: 您的 Storybook 可能需要特定的环境变量才能按预期运行。您可以按照 {{text:1}} 中所述设置自定义环境变量。" } - } + ] }, - "timestamp": 1784830460005, - "source": "loco-export" + "glossary": {}, + "aria": [], + "timestamp": 1784842843881 } diff --git a/src/utils/i18n.test.ts b/src/utils/i18n.test.ts index c9cc139a6..4a94d4c1c 100644 --- a/src/utils/i18n.test.ts +++ b/src/utils/i18n.test.ts @@ -43,18 +43,46 @@ describe('loco i18n utils', () => { }); it('resolves dotted keys for active language', () => { - expect(resolveLocoTranslation(samplePack, 'fr', 'ui.actions.save')).toBe('Enregistrer'); + expect(resolveLocoTranslation(samplePack, 'fr', 'ui.actions.save')).toBe( + 'Enregistrer' + ); }); it('falls back to fallback language when missing', () => { - expect(resolveLocoTranslation(samplePack, 'fr', 'ui.missing', 'en')).toBeUndefined(); - expect(resolveLocoTranslation(samplePack, 'fr', 'ui.title', 'en')).toBe('Parametres'); + expect( + resolveLocoTranslation(samplePack, 'fr', 'ui.missing', 'en') + ).toBeUndefined(); + expect(resolveLocoTranslation(samplePack, 'fr', 'ui.title', 'en')).toBe( + 'Parametres' + ); }); it('creates translator with key fallback behavior', () => { - const t = createLocoTranslator(samplePack, 'fr', { fallbackLanguage: 'en' }); + const t = createLocoTranslator(samplePack, 'fr', { + fallbackLanguage: 'en', + }); expect(t('ui.actions.save')).toBe('Enregistrer'); expect(t('ui.does.not.exist')).toBe('ui.does.not.exist'); expect(t('ui.does.not.exist', 'Default text')).toBe('Default text'); }); + + it('supports the native loco export format', () => { + const locoPack: LocoI18nPackage = { + languages: ['zh-Hans'], + translations: { + 'zh-Hans': [ + { key: 'Save', context: '', value: '保存' }, + { key: 'Cancel', context: '', value: '取消' }, + ], + }, + }; + + expect(getLocoDictionary(locoPack, 'zh-Hans')).toEqual({ + Save: '保存', + Cancel: '取消', + }); + const t = createLocoTranslator(locoPack, 'zh-Hans'); + expect(t('Save')).toBe('保存'); + expect(t('Untranslated phrase')).toBe('Untranslated phrase'); + }); }); diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 0103d54ef..ffd325796 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -4,12 +4,24 @@ export type LocoI18nLanguageResource = { translation?: LocoI18nDictionary; } & LocoI18nDictionary; +export type LocoTranslationEntry = { + key: string; + context?: string; + value: string; +}; + export type LocoI18nPackage = { format?: string; contextMode?: string; languages: string[]; languageNames?: Record; - resources: Record; + /** i18next-nested export shape: { lang: { translation: { key: value } } } */ + resources?: Record; + /** Native loco export shape: { lang: [{ key, context, value }] } or { lang: { key: value } } */ + translations?: Record< + string, + LocoTranslationEntry[] | Record + >; timestamp?: number; source?: string; }; @@ -23,7 +35,10 @@ function asObject(value: unknown): Record { return value as Record; } -function resolveDottedValue(dictionary: Record, dottedKey: string): string | undefined { +function resolveDottedValue( + dictionary: Record, + dottedKey: string +): string | undefined { const parts = dottedKey.split('.').filter(Boolean); if (parts.length === 0) return undefined; @@ -38,21 +53,39 @@ function resolveDottedValue(dictionary: Record, dottedKey: stri export function getLocoDictionary( i18nPackage: LocoI18nPackage, - language: string, + language: string ): Record { + // i18next-nested export shape const resource = asObject(i18nPackage.resources?.[language]); - if (!resource || Object.keys(resource).length === 0) return {}; + if (Object.keys(resource).length > 0) { + const nested = asObject(resource.translation); + if (Object.keys(nested).length > 0) return nested; + return resource; + } - const nested = asObject(resource.translation); - if (Object.keys(nested).length > 0) return nested; - return resource; + // Native loco export shape + const entries = i18nPackage.translations?.[language]; + if (Array.isArray(entries)) { + const dictionary: Record = {}; + for (const entry of entries) { + if ( + entry && + typeof entry.key === 'string' && + typeof entry.value === 'string' + ) { + dictionary[entry.key] = entry.value; + } + } + return dictionary; + } + return asObject(entries); } export function resolveLocoTranslation( i18nPackage: LocoI18nPackage, language: string, key: string, - fallbackLanguage?: string, + fallbackLanguage?: string ): string | undefined { const dictionary = getLocoDictionary(i18nPackage, language); @@ -75,19 +108,17 @@ export function resolveLocoTranslation( export function createLocoTranslator( i18nPackage: LocoI18nPackage, language: string, - options: LocoTranslatorOptions = {}, + options: LocoTranslatorOptions = {} ) { const fallbackLanguage = - options.fallbackLanguage || - i18nPackage.languages?.[0] || - language; + options.fallbackLanguage || i18nPackage.languages?.[0] || language; return (key: string, defaultValue?: string): string => { const translated = resolveLocoTranslation( i18nPackage, language, key, - fallbackLanguage, + fallbackLanguage ); return translated ?? defaultValue ?? key; }; diff --git a/src/utils/loco-live.ts b/src/utils/loco-live.ts index 9b64ad945..a67047c85 100644 --- a/src/utils/loco-live.ts +++ b/src/utils/loco-live.ts @@ -50,14 +50,19 @@ export function collectLocoKeysFromElement(root: HTMLElement): LocoKeyEntry[] { const parent = textNode.parentElement; if (parent) { const tagName = parent.tagName; - if (!SKIP_TAGS.has(tagName) && !parent.closest('[data-loco-ignore="true"]')) { + if ( + !SKIP_TAGS.has(tagName) && + !parent.closest('[data-loco-ignore="true"]') + ) { addPhrase(textNode.nodeValue || ''); } } node = walker.nextNode(); } - const attrNodes = root.querySelectorAll('[aria-label], [title], [placeholder]'); + const attrNodes = root.querySelectorAll( + '[aria-label], [title], [placeholder]' + ); for (const el of attrNodes) { if (el.closest('[data-loco-ignore="true"]')) continue; const ariaLabel = el.getAttribute('aria-label'); From c793701225031693c5eafca7667483581783588b Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Thu, 23 Jul 2026 18:33:37 -0400 Subject: [PATCH 07/12] fix(i18n): vendor Loco runtime locally so package mode works offline Package mode previously loaded the runtime script from the Loco server (/cdn/loco.js), so translations broke whenever the server was down even though the translation pack is committed to the repo. Vendor the built loco.min.js (includes the _applyInFlight deadlock fix) into src/i18n/ and serve it via staticDirs. Live mode still loads the runtime from the Loco server it syncs with (default: https://loco.os.mieweb.org). --- .storybook/preview.tsx | 22 ++++++++++++++++------ src/i18n/loco.min.js | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 src/i18n/loco.min.js diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 25ff05b88..3f02c328c 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -21,8 +21,11 @@ const postedLiveSyncSignatures = new Set(); const locoScriptLoaders = new Map>(); // The exported Loco pack is also served statically (see staticDirs in main.ts) -// so the Loco runtime can consume it in file mode. +// so the Loco runtime can consume it in file mode. The runtime itself is +// vendored from the Loco repo (public/loco.min.js) so package mode works +// fully offline — no Loco server required. const LOCO_PACK_URL = '/i18n/i18n-translations.json'; +const LOCO_RUNTIME_URL = '/i18n/loco.min.js'; const locoPackLanguages: string[] = Array.isArray((locoI18nPack as { languages?: string[] }).languages) ? (locoI18nPack as { languages: string[] }).languages : []; @@ -40,14 +43,15 @@ type LocoRuntime = { let locoInitializedMode: 'package' | 'live' | null = null; let locoInitPromise: Promise | null = null; -async function ensureLocoRuntimeLoaded(serverUrl: string): Promise { +async function ensureLocoRuntimeLoaded( + scriptUrl: string +): Promise { if (typeof window === 'undefined') return null; const runtime = (window as any).Loco as LocoRuntime | undefined; if (runtime?.init) return runtime; - const serverBase = serverUrl.replace(/\/$/, ''); - const scriptId = `loco-runtime-${serverBase}`; + const scriptId = `loco-runtime-${scriptUrl}`; let loader = locoScriptLoaders.get(scriptId); if (!loader) { @@ -61,7 +65,7 @@ async function ensureLocoRuntimeLoaded(serverUrl: string): Promise resolve(); script.onerror = () => reject(new Error(`Unable to load ${script.src}`)); @@ -89,7 +93,13 @@ async function ensureLocoInitialized( if (locoInitPromise) return locoInitPromise; locoInitPromise = (async () => { - const runtime = await ensureLocoRuntimeLoaded(serverUrl); + // Package mode uses the locally vendored runtime (fully offline); + // live mode loads the runtime from the Loco server it syncs with. + const scriptUrl = + mode === 'package' + ? LOCO_RUNTIME_URL + : `${serverUrl.replace(/\/$/, '')}/cdn/loco.js`; + const runtime = await ensureLocoRuntimeLoaded(scriptUrl); if (!runtime?.init) return runtime ?? null; if (mode === 'package') { diff --git a/src/i18n/loco.min.js b/src/i18n/loco.min.js new file mode 100644 index 000000000..b8e5679c2 --- /dev/null +++ b/src/i18n/loco.min.js @@ -0,0 +1,21 @@ +var Loco=function(){"use strict";var i={apiKey:null,apiBase:null,phrases:[],currentLang:null,translations:{},glossary:{},observer:null,fileMode:!1,fileData:null,fileReadyResolve:null,fileReady:null,fileUrl:null,fileUrls:[],scanStopped:!1,loadedFromCache:!1,screenshotsEnabled:!0,widgetPosition:null,ariaRules:{}},ze=["SCRIPT","STYLE","NOSCRIPT","IFRAME","CODE","SVG","AUDIO","VIDEO","LINK"],Ge=["VAR","STRONG","EM","B","I","SPAN","A","ABBR","MARK","SMALL","SUB","SUP","U","S","TIME","LABEL"],qe=["h1","h2","h3","h4","h5","h6","label","legend","caption","figcaption","form"],J=new Set(ze),Z=new Set(Ge),Ce=[].concat(qe),Ie=0,Re=!0,q=1e3,Pe={},B=1e4,ke="loco-lang";function Y(){try{return localStorage.getItem(ke)}catch{return null}}function U(e){try{e?localStorage.setItem(ke,e):localStorage.removeItem(ke)}catch{}}function bt(e){if(e.blockedTags){var n=e.blockedTags.disabled||[],t=e.blockedTags.custom||[];J=new Set(ze.filter(function(l){return n.indexOf(l)<0})),t.forEach(function(l){J.add(l.toUpperCase())})}if(e.inlineTags){var n=e.inlineTags.disabled||[],t=e.inlineTags.custom||[];Z=new Set(Ge.filter(function(f){return n.indexOf(f)<0})),t.forEach(function(f){Z.add(f.toUpperCase())})}if(e.domContextSelectors){var n=e.domContextSelectors.disabled||[],t=e.domContextSelectors.custom||[];Ce=qe.filter(function(f){return n.indexOf(f)<0}).concat(t)}if(e.screenshotsEnabled===!1&&(i.screenshotsEnabled=!1),typeof e.contextDepth=="number"&&(Ie=e.contextDepth),e.domContextEnabled===!1&&(Re=!1),e.languageDirections&&typeof e.languageDirections=="object"&&!Array.isArray(e.languageDirections)){var a={};for(var r in e.languageDirections)if(e.languageDirections.hasOwnProperty(r)){var o=e.languageDirections[r];(o==="ltr"||o==="rtl")&&(a[r]=o)}Pe=a}else Pe={}}function _(e){return e.trim().replace(/\s+/g," ")}function S(e){return!/[a-zA-Z\u00C0-\u024F\u0900-\u097F\u0600-\u06FF]/.test(e)}function ve(e){var n=e.replace(/\{\{(?:text|number|decimal|date):\d+\}\}/g,"");return!n.trim()||S(n)}function $e(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function ce(e){var t;if(!e||e.nodeType!==1)return!1;const n=(t=e.tagName)==null?void 0:t.toUpperCase();return J.has(n)||e.hasAttribute("notranslate")||e.hasAttribute("data-notranslate")||e.hasAttribute("data-loco-translated")||e.getAttribute("translate")==="no"||e.isContentEditable}var ee=new WeakMap;function Ve(e){let n=e.parentElement;for(;n&&n!==document.body;){if(ee.has(n))return ee.get(n);if(ce(n))return ee.set(n,!0),!0;n=n.parentElement}for(n=e.parentElement;n&&n!==document.body&&!ee.has(n);)ee.set(n,!1),n=n.parentElement;return!1}function Et(){ee=new WeakMap}const Ze=new Set(["H1","H2","H3","H4","H5","H6"]),wt=new Set(["SCRIPT","STYLE","NOSCRIPT","CODE"]);let te=new WeakMap,re=new WeakMap,ne=null;function Me(){if(Re){ne=new Set;var e=Ce.slice();try{var n=e.join(",");document.querySelectorAll(n).forEach(function(t){ne.add(t)})}catch{e.forEach(function(a){try{document.querySelectorAll(a).forEach(function(r){ne.add(r)})}catch{}})}ne.forEach(function(t){re.has(t)||fe(t)})}}function ue(e){return ne?ne.has(e):Ze.has(e.tagName)?!0:Ce.some(function(n){try{return e.matches(n)}catch{return!1}})}function Nt(e){let n="";for(const t of e.childNodes)if(t.nodeType===Node.TEXT_NODE){const a=t.textContent.trim();a&&(n+=(n?" ":"")+a)}return n}function At(e,n){for(var t="",a=document.createTreeWalker(e,NodeFilter.SHOW_ALL,{acceptNode:function(o){return o.nodeType===Node.ELEMENT_NODE?wt.has(o.tagName)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_SKIP:NodeFilter.FILTER_ACCEPT}}),r;(r=a.nextNode())&&!(r.nodeType===Node.TEXT_NODE&&(t+=r.textContent,t.length>=n)););return t.trim()}function fe(e){if(re.has(e))return re.get(e);let n=null;const t=Nt(e);if(t)return n=t.substring(0,60),re.set(e,n),n;if(Ze.has(e.tagName)){const r=(e.textContent||"").trim().replace(/\s+/g," ");if(r)return n=r.substring(0,60),re.set(e,n),n}var a=At(e,200);if(a){const r=a.split(/\n/)[0].trim().replace(/\s+/g," ");r.length>=2&&!S(r)&&(n=r.substring(0,60))}return re.set(e,n),n}function R(e){if(!e||!Re)return"";if(te.has(e))return te.get(e);var n=e.parentElement;if(n&&n!==document.body&&te.has(n)&&!ue(e)){var t=te.get(n);return te.set(e,t),t}const a=[],r=new Set;let o=e,l=0;if(e&&e.tagName==="TD"){var c=e.closest("table");if(c){var f=c.querySelector("thead tr th:nth-child("+(e.cellIndex+1)+")");if(f){var h=fe(f);h&&(r.add(h),a.push(h))}}}for(;o&&o!==document.body&&!(Ie>0&&l>=Ie);){l++;let p=o.previousElementSibling;for(;p;){var g=p.tagName.toUpperCase();if(J.has(g)){p=p.previousElementSibling;continue}if(g==="ARTICLE"||g==="MAIN"){p=p.previousElementSibling;continue}if(ue(p)){const u=fe(p);u&&!r.has(u)&&(r.add(u),a.unshift(u));break}let s=null;for(const u of p.children)if(ue(u)){s=u;break}if(!s)for(const u of p.children){for(const d of u.children)if(ue(d)){s=d;break}if(s)break}if(s){const u=fe(s);u&&!r.has(u)&&(r.add(u),a.unshift(u));break}p=p.previousElementSibling}if(ue(o)){const s=fe(o);s&&!r.has(s)&&(r.add(s),a.unshift(s))}var m=o.tagName;if(m==="ARTICLE"||m==="MAIN")break;o=o.parentElement}const v=a.join(" > ");return te.set(e,v),v}function Tt(e){return/^\d+$/.test(e)?"number":/^\d+\.\d+$/.test(e)?"decimal":"text"}function Lt(e,n){for(var t=n.split(/(\{\{(?:text|number|decimal|date):\d+\}\})/),a=[],r=e,o=0;o=0){f=g;break}}else if(g!==""){f=g;break}}var m;if(f==="")m=r,r="";else{var v=r.indexOf(f);v>=0?(m=r.substring(0,v),r=r.substring(v)):(m=r,r="")}a.push({type:c[1],index:parseInt(c[2]),originalText:m})}else r.indexOf(l)===0&&(r=r.substring(l.length))}return a}function ge(e,n){e.forEach(function(t){if(!(t.varSlots&&t.varSlots.length>0)){var a=n[t.key];a&&(t.varSlots=Lt(t.key,a),t.key=a)}})}function Dt(e,n){for(var t=n.split(/\{\{(?:text|number|decimal|date):\d+\}\}/g),a=e,r=[],o=0;o=0&&(r.push(a.substring(0,l)),a=a.substring(l+t[o].length))}return r.length>0&&r.every(function(c){return/^[a-zA-Z\s]+$/.test(c.trim())})}function Ot(e){for(var n=0,t=/\{\{(text|number|decimal|date):\d+\}\}/g,a;(a=t.exec(e))!==null;)switch(a[1]){case"number":n+=3;break;case"decimal":n+=3;break;case"date":n+=2;break;default:n+=1;break}return n}function ae(e,n){for(var t=[],a=0;aq)){for(var u=0;u=2&&!S(d.value)){var y=R(d.node||e),x=d.value+"\0"+y;t.has(x)||(t.add(x),n.push({type:"text",key:d.value,slots:[],textNode:d.node?d.node.firstChild:null,element:d.node||e,context:y,original:d.value}))}});else{var a=R(e),r=s+"\0"+a;t.has(r)||t.add(r);const d=u.some(y=>y.type==="date")?"mixed-date":u.some(y=>y.type==="text")?"mixed-text":"mixed";n.push({type:d,key:s,slots:u,element:e,context:a,original:s,originalHTML:e.innerHTML})}}const o=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT,{acceptNode(s){var w,N,A;if(s.nodeType===Node.TEXT_NODE){const D=_(s.textContent);if(!D||D.length<2||D.length>q||S(D)||/\{\{(?:text|number|decimal|date):\d+\}\}/.test(D)||Ve(s))return NodeFilter.FILTER_SKIP;const k=(N=(w=s.parentElement)==null?void 0:w.tagName)==null?void 0:N.toUpperCase();if(J.has(k)||Q(s.parentElement))return NodeFilter.FILTER_SKIP;for(var u=s.parentElement,d=0;u&&d<3;){var y=(A=u.tagName)==null?void 0:A.toUpperCase();if((y==="VAR"||y==="TIME"||Z.has(y))&&u.parentElement&&Q(u.parentElement))return NodeFilter.FILTER_SKIP;u=u.parentElement,d++}return NodeFilter.FILTER_ACCEPT}if(s.nodeType===Node.ELEMENT_NODE){const D=s.tagName.toUpperCase();if(J.has(D)||ce(s))return NodeFilter.FILTER_REJECT;if(D==="INPUT"){var x=(s.getAttribute("type")||"").toLowerCase();if(x==="button"||x==="submit"||x==="reset"){var b=_(s.value);if(b&&b.length>=2&&!S(b))return NodeFilter.FILTER_ACCEPT}var T=me.some(function(k){var F=_(s.getAttribute(k)||"");return F&&F.length>=2&&!S(F)});return T?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP}if(D==="OPTION"){var L=_(s.textContent||"");if(L&&L.length>=2&&!S(L))return NodeFilter.FILTER_ACCEPT;var E=_(s.getAttribute("value")||"");return E&&E.length>=2&&!S(E)?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP}return Q(s)?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP}return NodeFilter.FILTER_SKIP}});let l;for(;l=o.nextNode();){if(l.nodeType===Node.TEXT_NODE){const s=_(l.textContent);if(!s)continue;var a=R(l.parentElement),r=s+"\0"+a;t.has(r)||t.add(r),n.push({type:"text",key:s,slots:[],textNode:l,element:l.parentElement,context:a,original:s});continue}if(l.nodeType===Node.ELEMENT_NODE&&l.tagName.toUpperCase()==="INPUT"){var c=(l.getAttribute("type")||"").toLowerCase(),a=R(l);if(c==="button"||c==="submit"||c==="reset"){var f=_(l.value);if(f&&f.length>=2&&!S(f)){var r=f+"\0"+a;t.has(r)||t.add(r),n.push({type:"input-value",key:f,slots:[],element:l,context:a,original:f})}}me.forEach(function(u){var d=_(l.getAttribute(u)||"");if(!(!d||d.length<2||S(d))){var y=d+"\0"+a+"\0"+u;t.has(y)||(t.add(y),n.push({type:"input-attr",key:d,attr:u,slots:[],element:l,context:a,original:d}))}});continue}if(l.nodeType===Node.ELEMENT_NODE&&l.tagName.toUpperCase()==="OPTION"){var h=_(l.textContent||"");if(!h||h.length<2||S(h))continue;var g=R(l),m=h+"\0"+g;t.has(m)||t.add(m),n.push({type:"option-text",key:h,slots:[],textNode:l.firstChild&&l.firstChild.nodeType===Node.TEXT_NODE?l.firstChild:null,element:l,context:g,original:h});var v=_(l.getAttribute("value")||"");if(v&&v.length>=2&&!S(v)&&v!==h){var p=v+"\0"+g+"\0value";t.has(p)||t.add(p),n.push({type:"option-value",key:v,slots:[],element:l,attr:"value",context:g,original:v})}continue}if(l.nodeType===Node.ELEMENT_NODE){const{key:s,slots:u}=ye(l);if(!s||s.length>q)continue;if(ve(s)){u.forEach(function(b){if(b.type==="text"&&b.value&&b.value.length>=2&&!S(b.value)){var T=R(b.node||l),L=b.value+"\0"+T;t.has(L)||(t.add(L),n.push({type:"text",key:b.value,slots:[],textNode:b.node?b.node.firstChild:null,element:b.node||l,context:T,original:b.value}))}});continue}var a=R(l),r=s+"\0"+a;t.has(r)||t.add(r);const x=u.some(b=>b.type==="date")?"mixed-date":u.some(b=>b.type==="text")?"mixed-text":"mixed";n.push({type:x,key:s,slots:u,element:l,context:a,original:s,originalHTML:l.innerHTML})}}return n}async function pe(e,n){n||(n=100);const t=[],a=new Set;if(Et(),St(),Me(),e.nodeType===Node.ELEMENT_NODE&&!ce(e)&&Q(e)){const{key:p,slots:s}=ye(e);if(p&&p.length<=q)if(ve(p))s.forEach(function(u){if(u.type==="text"&&u.value&&u.value.length>=2&&!S(u.value)){var d=R(u.node||e),y=u.value+"\0"+d;a.has(y)||(a.add(y),t.push({type:"text",key:u.value,slots:[],textNode:u.node?u.node.firstChild:null,element:u.node||e,context:d,original:u.value}))}});else{var r=R(e),o=p+"\0"+r;a.has(o)||a.add(o);const u=s.some(d=>d.type==="date")?"mixed-date":s.some(d=>d.type==="text")?"mixed-text":"mixed";t.push({type:u,key:p,slots:s,element:e,context:r,original:p,originalHTML:e.innerHTML})}}const l=[],c=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT,{acceptNode(p){var T,L,E;if(p.nodeType===Node.TEXT_NODE){const w=_(p.textContent);if(!w||w.length<2||w.length>q||S(w)||/\{\{(?:text|number|decimal|date):\d+\}\}/.test(w)||Ve(p))return NodeFilter.FILTER_SKIP;const N=(L=(T=p.parentElement)==null?void 0:T.tagName)==null?void 0:L.toUpperCase();if(J.has(N)||Q(p.parentElement))return NodeFilter.FILTER_SKIP;for(var s=p.parentElement,u=0;s&&u<3;){var d=(E=s.tagName)==null?void 0:E.toUpperCase();if((d==="VAR"||d==="TIME"||Z.has(d))&&s.parentElement&&Q(s.parentElement))return NodeFilter.FILTER_SKIP;s=s.parentElement,u++}return NodeFilter.FILTER_ACCEPT}if(p.nodeType===Node.ELEMENT_NODE){const w=p.tagName.toUpperCase();if(J.has(w)||ce(p))return NodeFilter.FILTER_REJECT;if(w==="INPUT"){var y=(p.getAttribute("type")||"").toLowerCase();if(y==="button"||y==="submit"||y==="reset"){var x=_(p.value);if(x&&x.length>=2&&!S(x))return NodeFilter.FILTER_ACCEPT}var b=me.some(function(N){var A=_(p.getAttribute(N)||"");return A&&A.length>=2&&!S(A)});return b?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP}return Q(p)?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP}return NodeFilter.FILTER_SKIP}});for(var f;f=c.nextNode();)l.push(f);for(var h=0;h0&&h%n===0&&await new Promise(function(p){setTimeout(p,0)});var g=l[h];if(g.nodeType===Node.TEXT_NODE){const p=_(g.textContent);if(!p)continue;var r=R(g.parentElement),o=p+"\0"+r;a.has(o)||a.add(o),t.push({type:"text",key:p,slots:[],textNode:g,element:g.parentElement,context:r,original:p});continue}if(g.nodeType===Node.ELEMENT_NODE&&g.tagName.toUpperCase()==="INPUT"){var m=(g.getAttribute("type")||"").toLowerCase(),r=R(g);if(m==="button"||m==="submit"||m==="reset"){var v=_(g.value);if(v&&v.length>=2&&!S(v)){var o=v+"\0"+r;a.has(o)||a.add(o),t.push({type:"input-value",key:v,slots:[],element:g,context:r,original:v})}}me.forEach(function(s){var u=_(g.getAttribute(s)||"");if(!(!u||u.length<2||S(u))){var d=u+"\0"+r+"\0"+s;a.has(d)||(a.add(d),t.push({type:"input-attr",key:u,attr:s,slots:[],element:g,context:r,original:u}))}});continue}if(g.nodeType===Node.ELEMENT_NODE){const{key:p,slots:s}=ye(g);if(!p||p.length>q)continue;if(ve(p)){s.forEach(function(x){if(x.type==="text"&&x.value&&x.value.length>=2&&!S(x.value)){var b=R(x.node||g),T=x.value+"\0"+b;a.has(T)||(a.add(T),t.push({type:"text",key:x.value,slots:[],textNode:x.node?x.node.firstChild:null,element:x.node||g,context:b,original:x.value}))}});continue}var r=R(g),o=p+"\0"+r;a.has(o)||a.add(o);const y=s.some(x=>x.type==="date")?"mixed-date":s.some(x=>x.type==="text")?"mixed-text":"mixed";t.push({type:y,key:p,slots:s,element:g,context:r,original:p,originalHTML:g.innerHTML})}}return t}function xe(e){if(i.scanStopped)return Promise.resolve({ok:!0,registered:0,keyMap:{}});var n=new Set,t=[];return e.forEach(function(a){var r=a.key+"\0"+(a.context||"");n.has(r)||a.varSlots&&a.varSlots.length>0&&/\{\{(?:text|number|decimal|date):\d+\}\}/.test(a.key)||(n.add(r),t.push({key:a.key,context:a.context||""}),a.slots&&a.slots.length>0&&a.slots.forEach(function(o){if(o.type==="text"&&o.value&&!S(o.value)){var l=o.value+"\0";n.has(l)||(n.add(l),t.push({key:o.value,context:""}))}}))}),fetch(i.apiBase+"/api/textnodes",{method:"POST",headers:{"Content-Type":"application/json","X-API-Key":i.apiKey},body:JSON.stringify({keys:t,url:window.location.href})}).then(function(a){return a.json()})}function _t(e){var n=i.apiBase+"/api/glossary?lang="+encodeURIComponent(e);return fetch(n,{headers:{"X-API-Key":i.apiKey}}).then(function(t){return t.json()}).then(function(t){var a={};return Array.isArray(t)&&t.forEach(function(r){if(!(!r||typeof r.key!="string")){var o=r.context||"";typeof r.value=="string"&&(a[r.key+"\0"+o]=r.value,o===""&&(a[r.key+"\0"]=r.value))}}),a}).catch(function(){return{}})}function Ft(e,n){var t=i.apiBase+"/api/translations?lang="+encodeURIComponent(e);return fetch(t,{headers:{"X-API-Key":i.apiKey}}).then(function(a){return a.json()}).then(function(a){var r={},o=n||{};if(Array.isArray(a)){var l={};a.forEach(function(c){if(!(!c||typeof c.key!="string")){var f=c.key,h=c.context||"";l[f]||(l[f]={master:null,contexts:{}}),h===""?l[f].master=c.value:l[f].contexts[h]=c.value}}),Object.keys(l).forEach(function(c){var f=l[c],h=Object.keys(f.contexts),g=c+"\0",m=Object.prototype.hasOwnProperty.call(o,g);m?r[c]=o[g]:f.master!=null?r[c]=f.master:h.length>0&&(r[c]=f.contexts[h[0]]),h.forEach(function(v){var p=c+"\0"+v;Object.prototype.hasOwnProperty.call(o,p)?r[p]=o[p]:m?r[p]=o[g]:f.master!=null?r[p]=f.master:r[p]=f.contexts[v]})})}else a&&typeof a=="object"&&(r=a);return Object.keys(o).forEach(function(c){if(Object.prototype.hasOwnProperty.call(r,c)||(r[c]=o[c]),c.endsWith("\0")){var f=c.slice(0,-1);r[f]=o[c]}}),!Array.isArray(a)&&a&&typeof a=="object"&&Object.keys(r).forEach(function(c){c.endsWith("\0")&&(r[c.slice(0,-1)]=r[c])}),r})}function Ct(){if(!(i.fileMode||!i.screenshotsEnabled)){var e=document.createElement("script");e.src=i.apiBase+"/cdn/html2canvas.min.js",e.onload=function(){typeof html2canvas=="function"&&html2canvas(document.body,{scale:.35,logging:!1,useCORS:!0,allowTaint:!0,width:window.innerWidth,height:window.innerHeight,windowWidth:window.innerWidth,windowHeight:window.innerHeight}).then(function(n){var t=n.toDataURL("image/jpeg",.5),a=t.split(",")[1];!a||a.length>5e5||fetch(i.apiBase+"/api/screenshots",{method:"POST",headers:{"Content-Type":"application/json","X-API-Key":i.apiKey},body:JSON.stringify({url:window.location.href,screenshot:a})}).catch(function(){})}).catch(function(){})},e.onerror=function(){},document.head.appendChild(e)}}function It(e,n){return e.replace(/\{\{(text|number|decimal|date):(\d+)\}\}/g,(t,a,r)=>{const o=n[parseInt(r)];return o?a==="date"?o.display:o.value:t})}function et(e,n){if(!Array.isArray(e))return e;var t={};e.forEach(function(o){if(!(!o||typeof o.key!="string")){var l=o.key,c=o.context||"";t[l]||(t[l]={master:null,contexts:{}}),c===""?t[l].master=o.value:t[l].contexts[c]=o.value}});var a={},r=n||{};return Object.keys(t).forEach(function(o){var l=t[o],c=Object.keys(l.contexts),f=l.master,h=o+"\0",g=Object.prototype.hasOwnProperty.call(r,h),m=null;g?(m=r[h],a[o]=m):f!=null?(m=f,a[o]=f):c.length>0&&(m=l.contexts[c[0]],a[o]=m),c.forEach(function(v){var p=o+"\0"+v;Object.prototype.hasOwnProperty.call(r,p)?a[p]=r[p]:g?a[p]=r[h]:f!=null?a[p]=f:a[p]=l.contexts[v]})}),Object.keys(r).forEach(function(o){if(Object.prototype.hasOwnProperty.call(a,o)||(a[o]=r[o]),o.endsWith("\0")){var l=o.slice(0,-1);Object.prototype.hasOwnProperty.call(a,l)||(a[l]=r[o])}}),a}function Rt(e,n){for(var t=[],a=0;a{const o=r.key+"\0"+(r.context||"");var l=n[o]||n[r.key];if(!l&&r.slots&&r.slots.length>0){var c=r.slots.some(function(s){if(s.type!=="text"||!s.value)return!1;var u=s.value+"\0"+(r.context||""),d=s.value+"\0";return n.hasOwnProperty(u)||n.hasOwnProperty(d)||n.hasOwnProperty(s.value)});c&&(l=r.key)}if(!l){a++;return}if(r.type==="input-value"&&r.element)try{r.element.value=l,r.element.setAttribute("data-loco-translated",""),t++;return}catch(s){console.warn("[translate] Could not write to input value",s),a++;return}if(r.type==="input-attr"&&r.element&&r.attr)try{r.element.setAttribute(r.attr,l),r.element.setAttribute("data-loco-translated",""),t++;return}catch(s){console.warn("[translate] Could not write to input attr",s),a++;return}if(r.type==="option-text"&&r.textNode)try{r.textNode.nodeValue=l,r.element&&r.element.setAttribute("data-loco-translated",""),t++;return}catch(s){console.warn("[translate] Could not write to option text",s),a++;return}if(r.type==="option-value"&&r.element&&r.attr)try{r.element.setAttribute(r.attr,l),r.element.setAttribute("data-loco-translated",""),t++;return}catch(s){console.warn("[translate] Could not write to option value",s),a++;return}if(r.type==="text"&&r.textNode)try{var f=l;r.varSlots&&r.varSlots.length>0&&(f=f.replace(/\{\{(text|number|decimal|date):(\d+)\}\}/g,function(s,u,d){var y=r.varSlots.find(function(x){return(x.type||"text")===u&&x.index===+d});return y||(y=r.varSlots[+d]),y?y.originalText:s})),f=f.replace(/\{\{(?:text|number|decimal|date):\d+\}\}/g,""),r.textNode.nodeValue=f,r.textNode.parentElement&&r.textNode.parentElement.setAttribute("data-loco-translated",""),t++;return}catch(s){console.warn("[translate] Could not write to text node",s),a++;return}if(r.element){try{const s=It(l,r.slots);let u=l;var h={};r.slots.forEach(d=>{if(d.type==="date"&&d.node)d.node.textContent=d.display,h[d.index]=d.node.outerHTML;else if(d.node){if(d.type==="text"&&d.value){var y=d.value+"\0"+(r.context||""),x=d.value+"\0",b=n[y]||n[x]||n[d.value];b&&(d.node.children&&d.node.children.length>0?Rt(d.node,b):d.node.textContent=b)}h[d.index]=d.node.outerHTML}}),r.varSlots&&r.varSlots.length>0&&r.varSlots.forEach(function(d){h.hasOwnProperty(d.index)||(h[d.index]=$e(d.originalText))});var g=l.match(/\{\{(?:text|number|decimal|date):(\d+)\}\}/g)||[],m=new Set(g.map(function(d){var y=d.match(/\{\{(?:text|number|decimal|date):(\d+)\}\}/);return y?+y[1]:-1}).filter(function(d){return d>=0})),v=r.slots.every(function(d){return m.has(d.index)});if(r.slots.length>0&&!v){r.element.setAttribute("data-loco-translated",""),t++;return}var p=u.split(/(\{\{(?:text|number|decimal|date):\d+\}\})/g);u=p.map(function(d){var y=d.match(/^\{\{(?:text|number|decimal|date):(\d+)\}\}$/);if(y){var x=+y[1];return h.hasOwnProperty(x)?h[x]:""}return $e(d)}).join(""),r.element.innerHTML=u,r.element.setAttribute("data-loco-translated",""),t++}catch(s){console.warn("[translate] Could not apply mixed translation",s),a++}return}a++}),{applied:t,skipped:a}}async function be(e,n,t){t||(t=100);for(var a=0,r=0,o=performance.now(),l=0;l0&&performance.now()-o>16&&(await new Promise(function(h){requestAnimationFrame(h)}),o=performance.now());var c=e.slice(l,l+t),f=je(c,n);a+=f.applied,r+=f.skipped}return{applied:a,skipped:r}}function $(e){let n=0;e.forEach(t=>{try{t.type==="input-value"&&t.element?(t.element.value=t.original,t.element.removeAttribute("data-loco-translated"),n++):t.type==="input-attr"&&t.element&&t.attr?(t.element.setAttribute(t.attr,t.original),t.element.removeAttribute("data-loco-translated"),n++):t.type==="text"&&t.textNode&&(t.textNode.nodeValue=t.original,t.textNode.parentElement&&t.textNode.parentElement.removeAttribute("data-loco-translated"),n++)}catch(a){console.warn("[translate] Could not restore text node",a)}}),e.forEach(t=>{if(t.type!=="text")try{t.element&&t.originalHTML!==void 0&&(t.element.innerHTML=t.originalHTML,t.element.removeAttribute("data-loco-translated"),n++)}catch(a){console.warn("[translate] Could not restore node",a)}}),console.log("[loco] untranslated "+n+" phrase(s)")}function Ee(e,n){const t=new Set(e),a=[];Object.values(n).forEach(function(s){if(s&&(t.add(s),/\{\{(text|number|decimal|date):\d+\}\}/.test(s))){var u=s.split(/\{\{(?:text|number|decimal|date):\d+\}\}/g),d=u.map(function(y){return y.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")});a.push(new RegExp("^"+d.join(".+")+"$"))}});function r(s){if(t.has(s))return!0;for(var u=0;u{var y=d.key+"\0"+(d.context||"");r(d.key)||t.has(y)||(t.add(y),t.add(d.key),o.push(d),i.phrases.push(d))}),ae(u,Object.keys(n)),g=!0,je(u,n),h&&h.takeRecords(),g=!1,i.fileMode||(clearTimeout(l),l=setTimeout(m,500))}function p(s){f.add(s),c||(c=requestAnimationFrame(function(){c=null;var u=Array.from(f);f.clear(),u.forEach(function(d){d.isConnected&&v(d)})}))}return h=new MutationObserver(s=>{if(!g){var u=[],d=new Set;for(const b of s){if(b.type==="characterData"){var y=b.target&&b.target.parentElement;y&&y.isConnected&&!y.isContentEditable&&d.add(y);continue}for(const T of b.addedNodes)if(T.nodeType===Node.ELEMENT_NODE)u.push(T);else if(T.nodeType===Node.TEXT_NODE){var x=T.parentElement;x&&x.isConnected&&d.add(x)}}u.forEach(function(b){v(b)}),d.forEach(function(b){p(b)})}}),h.observe(document.body,{childList:!0,subtree:!0,characterData:!0}),h}var tt=["aria-labelledby","aria-label","aria-describedby","role","aria-hidden","alt","title"],rt="\0";function Pt(e){var n={};return!e||typeof e!="object"||tt.forEach(function(t){if(Object.prototype.hasOwnProperty.call(e,t)){var a=e[t];typeof a=="string"&&a.trim()!==""&&(n[t]=a.trim())}}),n}function nt(e){var n={};return Array.isArray(e)&&e.forEach(function(t){if(!(!t||typeof t.key!="string")){var a=typeof t.context=="string"?t.context:"",r=Pt(t.attributes);Object.keys(r).length!==0&&(n[t.key+rt+a]=r,Object.prototype.hasOwnProperty.call(n,t.key)||(n[t.key]=r))}}),n}function kt(e){return e.element?e.element:e.textNode&&e.textNode.parentElement?e.textNode.parentElement:null}function at(){return!i.apiKey||!i.apiBase?Promise.resolve({}):fetch(i.apiBase+"/api/aria",{headers:{"X-API-Key":i.apiKey}}).then(function(e){return e.json()}).then(function(e){return nt(e)}).catch(function(e){return console.warn("[loco] Failed to fetch ARIA rules:",e),{}})}function Mt(e,n){if(!e||!n)return!1;var t=e.__locoAriaPrev||{},a=!1;return tt.forEach(function(r){if(Object.prototype.hasOwnProperty.call(n,r)){var o=n[r];if(!(typeof o!="string"||o.trim()==="")){Object.prototype.hasOwnProperty.call(t,r)||(t[r]=e.hasAttribute(r)?e.getAttribute(r):null);try{e.setAttribute(r,o.trim()),a=!0}catch(l){console.warn("[loco] Could not set "+r,l)}}}}),a&&(e.__locoAriaPrev=t,e.setAttribute("data-loco-aria","")),a}function ie(e,n){if(!n)return{applied:0};var t=0;return e.forEach(function(a){var r=a.key+rt+(a.context||""),o=n[r]||n[a.key];if(o){var l=kt(a);l&&Mt(l,o)&&t++}}),{applied:t}}var it="loco",ot=3;function oe(){return new Promise(function(e,n){try{var t=indexedDB.open(it,ot);t.onupgradeneeded=function(a){var r=a.target.result,o=a.target.transaction,l=a.oldVersion||0;if(r.objectStoreNames.contains("meta")&&a.oldVersion<2&&r.deleteObjectStore("meta"),r.objectStoreNames.contains("meta")||r.createObjectStore("meta",{keyPath:"key"}),r.objectStoreNames.contains("translations")||r.createObjectStore("translations",{keyPath:"lang"}),l>0&&l=0?"right:0;":"left:0;")+"background:#fff;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.18);min-width:200px;max-height:320px;overflow-y:auto;padding:6px 0;direction:ltr;text-align:left;unicode-bidi:isolate;";var b=document.createElement("div");b.textContent="Original",b.style.cssText="padding:10px 16px;cursor:pointer;color:#333;transition:background 0.15s;font-weight:600;text-align:left;direction:ltr;border-bottom:1px solid #eee;",b.onmouseenter=function(){b.style.background="#f5f5f5"},b.onmouseleave=function(){b.style.background="transparent"},b.onclick=function(){p||(v=null,L(),x.style.display="none",s(null))},x.appendChild(b);var T=[];r.forEach(function(E){var w=document.createElement("div");w.textContent=m(E),w.setAttribute("data-lang",E),w.style.cssText="padding:10px 16px;cursor:pointer;color:#333;transition:background 0.15s;text-align:left;direction:ltr;",w.onmouseenter=function(){w.style.background="#f5f5f5"},w.onmouseleave=function(){w.style.background=v===E?"#e8f0fe":"transparent"},w.onclick=function(){p||(v=E,L(),x.style.display="none",s(E))},x.appendChild(w),T.push({el:w,code:E})});function L(){b.style.background=v===null?"#e8f0fe":"transparent",b.style.fontWeight=v===null?"600":"400",T.forEach(function(E){E.el.style.background=v===E.code?"#e8f0fe":"transparent",E.el.style.fontWeight=v===E.code?"600":"400"})}y.onclick=function(E){E.stopPropagation(),x.style.display=x.style.display==="none"?"block":"none"},document.addEventListener("click",function(E){u.contains(E.target)||(x.style.display="none")}),L(),u.appendChild(x),u.appendChild(y),document.body.appendChild(u)}function Ht(e){Be&&(!e||e.length===0||Ae(e,Be))}var P={__proto__:1,constructor:1,prototype:1};function st(e){if(!e||typeof e!="string")return!1;if(e.charAt(0)==="/"||e.charAt(0)===".")return!0;try{var n=new URL(e,window.location.origin);return n.protocol==="http:"||n.protocol==="https:"}catch{return!1}}function Te(e){if(!e||typeof e!="object"||Array.isArray(e))return null;if(Array.isArray(e._raw)){for(var n={},t={},a=0;a20)return null;o.languages.push(e.languages[l])}if(o.timestamp=typeof e.timestamp=="number"&&isFinite(e.timestamp)?e.timestamp:0,o.languageNames={},e.languageNames&&typeof e.languageNames=="object"&&!Array.isArray(e.languageNames))for(var c in e.languageNames)!e.languageNames.hasOwnProperty(c)||P[c]||typeof e.languageNames[c]=="string"&&e.languageNames[c].length<=100&&(o.languageNames[c]=e.languageNames[c]);if(o.languageDirections={},e.languageDirections&&typeof e.languageDirections=="object"&&!Array.isArray(e.languageDirections)){for(var f in e.languageDirections)if(!(!e.languageDirections.hasOwnProperty(f)||P[f])&&o.languages.indexOf(f)!==-1){var h=e.languageDirections[f];(h==="ltr"||h==="rtl")&&(o.languageDirections[f]=h)}}if(o.translations={},e.translations&&typeof e.translations=="object"&&!Array.isArray(e.translations)){for(var g in e.translations)if(!(!e.translations.hasOwnProperty(g)||P[g])&&o.languages.indexOf(g)!==-1){var m=e.translations[g];if(Array.isArray(m)){for(var v=[],p=0;pB||s.value.length>B||P[s.key]||v.push({key:s.key,value:s.value,context:typeof s.context=="string"?s.context:""})}o.translations[g]=v}else if(typeof m=="object"){var u={};for(var d in m)!m.hasOwnProperty(d)||P[d]||typeof m[d]=="string"&&(d.length>B||m[d].length>B||(u[d]=m[d]));o.translations[g]=u}}}if(o.glossary={},e.glossary&&typeof e.glossary=="object"&&!Array.isArray(e.glossary)){for(var y in e.glossary)if(!(!e.glossary.hasOwnProperty(y)||P[y])&&o.languages.indexOf(y)!==-1){var x=e.glossary[y],b={};if(Array.isArray(x))for(var T=0;TB||L.value.length>B)){var E=typeof L.context=="string"?L.context:"";b[L.key+"\0"+E]=L.value,E===""&&(b[L.key+"\0"]=L.value)}}else if(x&&typeof x=="object")for(var w in x)!x.hasOwnProperty(w)||P[w]||typeof x[w]=="string"&&(w.length>B||x[w].length>B||(b[w]=x[w]));o.glossary[y]=b}}if(o.aria=[],Array.isArray(e.aria))for(var N=0;NB)&&!P[r.key]){var A=typeof r.context=="string"?r.context:"",D=r.attributes;!D||typeof D!="object"||Array.isArray(D)||o.aria.push({key:r.key,context:A,attributes:D})}}return o}async function ct(){try{var e=await fetch(i.apiBase+"/api/project/crawler-config",{headers:{"X-API-Key":i.apiKey}});e.ok&&bt(await e.json())}catch{}Me(),i.phrases=await pe(document.body),xe(i.phrases).then(function(t){t&&t.keyMap&&ge(i.phrases,t.keyMap)}).catch(function(t){console.warn("[loco] Failed to register keys:",t)}),at().then(function(t){i.ariaRules=t||{};var a=ie(i.phrases,i.ariaRules);a.applied>0&&console.log("[loco] applied ARIA rules to "+a.applied+" element(s)")}).catch(function(){}),!i.scanStopped&&i.screenshotsEnabled&&setTimeout(Ct,3e3),console.log("[loco] "+i.phrases.length+" text nodes discovered"),console.log(` Loco.textnodes() — list all discovered text nodes + Loco.apply("zh-Hans") — apply translations for a language + Loco.restore() — revert to original text + Loco.rescan() — re-scan DOM for new nodes`);var n=Y();n?O.apply(n):i.observer=Ee(i.phrases.map(function(t){return t.key+"\0"+(t.context||"")}),{})}function Wt(e,n){for(var t="\0",a={},r=[],o=0;o0&&ie(i.phrases,i.ariaRules)}async function ut(e){try{indexedDB.deleteDatabase("loco-translations")}catch{}try{indexedDB.deleteDatabase("loco-meta")}catch{}try{indexedDB.databases&&indexedDB.databases().then(function(o){o.forEach(function(l){if(l.name&&l.name.indexOf("loco-tr-")===0)try{indexedDB.deleteDatabase(l.name)}catch{}})}).catch(function(){})}catch{}i.fileUrls=e;var n=null,t=!1;try{if(n=await Bt(),n&&n.translations){var a=await Promise.all(e.map(function(o){return Ke(o).catch(function(){return null})}));t=a.every(function(o){return!!o})}}catch{}n&&n.translations&&t?(i.fileData={languages:n.languages,languageNames:n.languageNames||{},languageDirections:n.languageDirections||{},translations:n.translations,glossary:n.glossary||{},aria:n.aria||[],timestamp:0},i.loadedFromCache=!0,await Le("cache"),i.fileReadyResolve&&(i.fileReadyResolve(),i.fileReadyResolve=null),qt(e)):(await Gt(e),await Le("network"),i.fileReadyResolve&&(i.fileReadyResolve(),i.fileReadyResolve=null));try{var r=await jt();r&&O.pullLatest({forceFresh:!0,clearCache:!0,applyLanguage:!0}).catch(function(){})}catch{}}function Xt(){return i.fileUrls.length>0?i.fileUrls.slice():i.fileUrl?[i.fileUrl]:[]}var Jt=100;function ft(e){if(!e)return 0;var n=String(e).toLowerCase(),t=n;try{t=decodeURIComponent(n)}catch{}return t.indexOf("clientspecifictranslationsjson")!==-1?Jt:0}function Qt(e){if(typeof e=="string")return{url:e,priority:ft(e)};if(e&&typeof e=="object"&&typeof e.url=="string"){var n=typeof e.priority=="number"&&isFinite(e.priority)?e.priority:ft(e.url);return{url:e.url,priority:n}}return null}function zt(e){return e.slice().sort(function(n,t){return n.priority-t.priority})}function De(e,n){var t=n?{cache:"reload"}:void 0;return fetch(e,t).then(function(a){if(!a.ok)throw new Error("HTTP "+a.status+" for "+e);return a.text()})}async function Gt(e,n){for(var t={},a=!!t.forceFresh,r=e.map(function(m){return De(m,a).then(function(v){if(!v||!v.trim())return null;var p;try{p=JSON.parse(v)}catch{return null}var s=Te(p);return s?{url:m,data:s}:(console.warn("[loco] File rejected (invalid schema): "+m),null)}).catch(function(v){return console.warn("[loco] Failed to load "+m+":",v),null})}),o=await Promise.all(r),l=!0,c=0;c0){for(var a="\0",r={},o=[],l=0;li.fileData.timestamp&&(i.fileData.timestamp=e.timestamp)}function qt(e){var n=e.map(function(t){return Promise.all([Ke(t).catch(function(){return null}),De(t,!0).catch(function(){return null})]).then(function(a){var r=a[0],o=a[1];if(!o||!o.trim())return null;var l;try{l=JSON.parse(o)}catch{return null}if(l=Te(l),!l)return null;var c=l.timestamp||0,f=r&&r.timestamp||0;if(c!==f){var h=e.length>1||(l.languages||[]).length===1;return Ne(t,l,h).catch(function(){}),l}return null})});Promise.all(n).then(function(t){for(var a=!1,r=0;r=0){var o=document.getElementById(this.acvarname+"_choices_"+a);o&&(r=(o.textContent||"").trim())}if(r){if(this.f.value!==r){this.f.value=r,this.value=r;var l=document.createEvent("Event");l.initEvent("input",!0,!0),this.f.dispatchEvent(l)}return}if(this.f.value){var c=this.f.value,f=n.translations[c+"\0"]||n.translations[c];if(f&&f!==c){this.f.value=f,this.value=f;var h=document.createEvent("Event");h.initEvent("input",!0,!0),this.f.dispatchEvent(h)}}}}}function rr(e){return!e||typeof e!="object"?!1:e.constructor&&e.constructor.name==="jsXMLAutoComplete"?!0:!!(typeof e.SetSelection=="function"&&typeof e.GetData=="function"&&e.f&&typeof e.f=="object"&&"value"in e.f)}function mt(e,n){if(!rr(e)||typeof e.SetSelection!="function"||e.SetSelection.__locoWrappedSetSelection)return!1;var t=tr(e.SetSelection,n);return t.__locoWrappedSetSelection=!0,e.SetSelection=t,!0}function nr(e){if(typeof window>"u")return 0;var n=0,t=[],a=[];try{a=Object.getOwnPropertyNames(window)}catch{for(var r in window)a.push(r)}for(var o=0;o"u")return!1;var e;try{e=Object.getOwnPropertyDescriptor(window,"jxacBoth")}catch{return!1}if(e&&e.configurable===!1)return!1;var n=e&&typeof e.get=="function"?e.get:null,t=e&&typeof e.set=="function"?e.set:null,a=e&&"value"in e?e.value:void 0;try{Object.defineProperty(window,"jxacBoth",{configurable:!0,enumerable:!!(!e||e.enumerable),get:function(){if(n)try{return n.call(window)}catch{return a}return a},set:function(r){if(t)try{t.call(window,r)}catch{a=r}else a=r;V()}})}catch{return!1}return typeof window.jxacBoth=="function"&&V(),!0}function ir(){typeof window>"u"||typeof MutationObserver>"u"||Je||!document||!document.documentElement||(Je=new MutationObserver(function(e){for(var n=0;n"u"||yt||(yt=!0,V(),ar(),ir(),window.addEventListener("load",V,{once:!0}))}function V(){if(typeof window>"u"||typeof window.jxacBoth!="function")return 0;var e=i;if(!er(window.jxacBoth)){var n=window.jxacBoth,t=function(r,o,l,c,f){n.call(this,r,o,l,c,f),mt(r,e)};t.__locoWrappedJxacBoth=!0,window.jxacBoth=t}return nr(e)}var O={version:"1.3.0",init:function(e){if(!e){console.warn("[loco] Loco.init() requires a config object");return}if(e.glossary&&typeof e.glossary=="object"&&!Array.isArray(e.glossary)&&(i.glossary=e.glossary),e.file||e.files){i.fileMode=!0,i.fileReady=new Promise(function(l){i.fileReadyResolve=l});for(var n=e.files?e.files.slice():[e.file],t=[],a=0;a20||!/^[a-zA-Z0-9\-_]+$/.test(e)){console.warn("[loco] Invalid language code: "+e);return}if(O._applyInFlight){console.warn("[loco] Loco.apply() called while previous apply is in progress — ignoring duplicate call");return}if(O._applyInFlight=!0,i.fileMode){if(!i.fileData){if(i.fileReady)return O._applyInFlight=!1,await i.fileReady,O.apply(e);console.warn("[loco] File not loaded yet. Call Loco.init({ file }) first."),O._applyInFlight=!1;return}if(!i.fileData.translations||!i.fileData.translations[e]){try{var t=await O.pullLatest({forceFresh:!0,clearCache:!1,langCode:e,applyLanguage:!1});if(t&&t.status!=="error"&&i.fileData&&i.fileData.translations&&i.fileData.translations[e])return O._applyInFlight=!1,O.apply(e)}catch{}try{var a=await Kt(e);if(!a||Object.keys(a).length===0){console.warn('[loco] No translations for "'+e+'" in file data or cache'),O._applyInFlight=!1;return}return i.fileData.translations||(i.fileData.translations={}),i.fileData.translations[e]=a,i.fileData.languages.indexOf(e)===-1&&i.fileData.languages.push(e),O._applyInFlight=!1,O.apply(e)}catch(v){console.warn('[loco] Failed to load translations for "'+e+'" from cache:',v),O._applyInFlight=!1;return}}i.observer&&(i.observer.disconnect(),i.observer=null),$(i.phrases);var r=i.fileData.glossary&&i.fileData.glossary[e]||{},o=i.fileData.translations[e];!Array.isArray(o)&&i.fileData._rawTranslations&&i.fileData._rawTranslations[e]&&(o=i.fileData._rawTranslations[e]),i.translations=et(o,r),Array.isArray(o)&&(i.fileData._rawTranslations||(i.fileData._rawTranslations={}),i.fileData._rawTranslations[e]=o),n&&performance.mark("loco-collect-start");var l=gt();l.length===0&&(i.phrases=await pe(document.body)),n&&(performance.mark("loco-collect-end"),performance.measure("loco-collection-time","loco-collect-start","loco-collect-end"));var c=Object.keys(i.translations);ae(i.phrases,c),n&&performance.mark("loco-dom-start");var f=await be(i.phrases,i.translations);return n&&(performance.mark("loco-dom-end"),performance.measure("loco-dom-write-time","loco-dom-start","loco-dom-end"),performance.measure("loco-apply-total","loco-apply-start","loco-dom-end")),i.ariaRules&&Object.keys(i.ariaRules).length>0&&ie(i.phrases,i.ariaRules),i.observer=Ee(i.phrases.map(function(v){return v.key+"\0"+(v.context||"")}),i.translations),i.currentLang=e,le(e),U(e),V(),Fe(),O._applyInFlight=!1,console.log("[loco] (file) applied "+f.applied+" translation(s), "+f.skipped+" pending"),f}if(!i.apiKey||!i.apiBase){console.warn("[loco] Call Loco.init() first"),O._applyInFlight=!1;return}i.observer&&(i.observer.disconnect(),i.observer=null),$(i.phrases);try{var h=await _t(e);i.glossary[e]=h||{};var g=await Ft(e,i.glossary[e]);i.translations=g||{},n&&performance.mark("loco-collect-start");var m=gt();m.length===0&&(i.phrases=await pe(document.body)),n&&(performance.mark("loco-collect-end"),performance.measure("loco-collection-time","loco-collect-start","loco-collect-end")),ae(i.phrases,Object.keys(i.translations)),xe(i.phrases).then(function(p){p&&p.keyMap&&(ge(i.phrases,p.keyMap),be(i.phrases,i.translations))}).catch(function(){}),n&&performance.mark("loco-dom-start");var f=await be(i.phrases,i.translations);return n&&(performance.mark("loco-dom-end"),performance.measure("loco-dom-write-time","loco-dom-start","loco-dom-end"),performance.measure("loco-apply-total","loco-apply-start","loco-dom-end")),i.ariaRules&&Object.keys(i.ariaRules).length>0&&ie(i.phrases,i.ariaRules),i.observer=Ee(i.phrases.map(function(p){return p.key+"\0"+(p.context||"")}),i.translations),i.currentLang=e,le(e),U(e),V(),Fe(),O._applyInFlight=!1,console.log("[loco] applied "+f.applied+" translation(s), "+f.skipped+" pending"),f}catch(v){O._applyInFlight=!1,console.warn("[loco] Failed to fetch translations:",v)}},restore:function(){$(i.phrases),i.observer&&i.observer.disconnect(),i.currentLang=null,le(null),U(null)},rescan:async function(){var e=Object.keys(i.translations).length>0;e&&$(i.phrases),i.currentLang=null,i.phrases=await pe(document.body),i.fileMode||xe(i.phrases).catch(function(){}),i.ariaRules&&Object.keys(i.ariaRules).length>0&&ie(i.phrases,i.ariaRules),e&&(ae(i.phrases,Object.keys(i.translations)),await be(i.phrases,i.translations))},textnodes:function(){return i.phrases.map(function(e){return{key:e.key,context:e.context||"",element:e.element}})},aria:async function(){var e=await at();i.ariaRules=e||{};var n=ie(i.phrases,i.ariaRules);return console.log("[loco] applied ARIA rules to "+n.applied+" element(s)"),n},stopScan:function(){i.scanStopped=!0,i.observer&&(i.observer.disconnect(),i.observer=null),console.log("[loco] scanning stopped, no further nodes will be sent to dashboard")},startScan:function(){i.scanStopped=!1,console.log("[loco] scanning resumed")},isFileMode:function(){return i.fileMode},languages:function(){if(i.fileMode){var e=function(){var n=i.fileData?i.fileData.languages||[]:[],t=i.fileData&&i.fileData.languageNames||{};return n.map(function(a){return{code:a,name:Xe(a,t)||a}})};return!i.fileData&&i.fileReady?i.fileReady.then(function(){return we().then(function(n){return n&&n.languages&&n.languages.length>0?_e(n.languages,n.languageNames):e()}).catch(function(){return e()})}):we().then(function(n){return n&&n.languages&&n.languages.length>0?_e(n.languages,n.languageNames):e()}).catch(function(){return e()})}return!i.apiKey||!i.apiBase?Promise.resolve([]):fetch(i.apiBase+"/api/languages",{headers:{"X-API-Key":i.apiKey}}).then(function(n){return n.json()}).then(function(n){return Array.isArray(n)?n.map(function(t){if(t&&typeof t=="object"&&t.code){var a=t.name&&t.name!==t.code?t.name:vt(t.code);return{code:t.code,name:a||t.code}}return t}):[]}).catch(function(){return[]})},widget:function(e){e=e||{};var n=e.position||"bottom-right";if(i.widgetPosition=n,i.fileMode){let t=function(){we().then(function(r){var o;if(r&&r.languages&&r.languages.length>0?o=_e(r.languages,r.languageNames):o=a(),o.length===0){console.warn("[loco] No languages found in translation file or cache");return}Ae(o,n)}).catch(function(){var r=a();if(r.length===0){console.warn("[loco] No languages found in translation file");return}Ae(r,n)})},a=function(){var r=i.fileData?i.fileData.languages||[]:[],o=i.fileData&&i.fileData.languageNames||{};return r.map(function(l){var c=Xe(l,o);return c?{code:l,name:c}:l})};if(!i.fileData&&i.fileReady){i.fileReady.then(t);return}t();return}if(!i.apiKey||!i.apiBase){console.warn("[loco] Call Loco.init() first");return}fetch(i.apiBase+"/api/languages",{headers:{"X-API-Key":i.apiKey}}).then(function(t){return t.json()}).then(function(t){if(!Array.isArray(t)||t.length===0){console.warn("[loco] No languages found — add translations in the dashboard first");return}Ae(t,n)}).catch(function(t){console.warn("[loco] Failed to fetch languages:",t)})},clearCache:function(){return i.fileMode?lt().then(function(){i.fileData&&(i.fileData.translations={},i.fileData.timestamp=0),i.currentLang=null,i.loadedFromCache=!1,U(null),$(i.phrases),i.observer&&(i.observer.disconnect(),i.observer=null),console.log("[loco] IndexedDB cache cleared — call pullLatest() to re-fetch")}).catch(function(e){console.warn("[loco] Failed to clear cache:",e)}):(console.warn("[loco] clearCache() is only available in file mode"),Promise.resolve())},addFile:function(e){return i.fileMode?e?st(e)?(i.fileUrls.indexOf(e)===-1&&i.fileUrls.push(e),De(e,!0).then(function(n){if(!n||!n.trim())throw new Error("Empty file");var t;try{t=JSON.parse(n)}catch{throw new Error("Invalid JSON")}if(t=Te(t),!t)throw new Error("Invalid file schema");var a=!i.fileData||!i.fileData.languages||i.fileData.languages.length===0,r=!a||(t.languages||[]).length===1;return Oe(t,a),Ne(e,t,r).catch(function(){}),Ue(),console.log("[loco] added file: "+e+" ("+(t.languages||[]).join(", ")+")"),{status:"added",languages:t.languages||[]}}).catch(function(n){return console.warn("[loco] addFile() failed:",n),{status:"error",reason:n.message}})):(console.warn("[loco] Blocked unsafe URL: "+e),Promise.resolve({status:"error",reason:"unsafe URL"})):Promise.resolve({status:"error",reason:"no URL provided"}):(console.warn("[loco] addFile() is only available in file mode"),Promise.resolve({status:"error",reason:"not in file mode"}))},updateLatest:async function(e,n){if(!i.fileMode)return console.warn("[loco] updateLatest() is only available in file mode"),{status:"skipped",reason:"not in file mode"};var t=n||{},a=e||t.langCode||i.currentLang||Y()||(i.fileData&&i.fileData.languages||[])[0]||null,r=await O.pullLatest({forceFresh:!0,clearCache:t.clearCache!==!1,langCode:a,applyLanguage:!0});return a&&(U(a),le(a)),r},pullLatest:function(e){if(!i.fileMode)return console.warn("[loco] pullLatest() is only available in file mode"),Promise.resolve({status:"skipped",reason:"not in file mode"});var n=e||{},t=!!n.forceFresh,a=!!n.clearCache,r=n.langCode||null,o=n.applyLanguage!==!1,l=Xt();return l.length===0?(console.warn("[loco] No file URL(s) configured. Call Loco.init({ file }) first."),Promise.resolve({status:"error",reason:"no file URLs"})):Promise.resolve().then(async function(){a&&(await lt().catch(function(){}),i.fileData=null,i.loadedFromCache=!1);var c=l.map(function(f){return Promise.all([t?Promise.resolve(null):Ke(f).catch(function(){return null}),De(f,t)]).then(function(h){var g=h[0],m=h[1];if(!m||!m.trim())return{url:f,status:"current",reason:"empty"};var v;try{v=JSON.parse(m)}catch{throw new Error("Invalid JSON in "+f)}if(v=Te(v),!v)throw new Error("Invalid file schema in "+f);var p=v.timestamp||0,s=g&&g.timestamp||0;if(!t&&p===s&&s!==0){var u=i.fileData&&i.fileData.translations,d=v.languages||[],y=!u||d.some(function(T){return!i.fileData.translations[T]});if(!y)return{url:f,status:"current",timestamp:s}}var x=!i.fileData||!i.fileData.languages||i.fileData.languages.length===0,b=!x||(v.languages||[]).length===1;return Oe(v,x),Ne(f,v,b).catch(function(){}),{url:f,status:"updated",timestamp:p,previousTimestamp:s}}).catch(function(h){return{url:f,status:"error",reason:h.message}})});return Promise.all(c)}).then(async function(c){var f=c.some(function(u){return u.status==="updated"}),h=r||Y();if(f||t){if(i.loadedFromCache=!1,i.currentLang=null,h&&o)U(h),le(h),i.observer&&(i.observer.disconnect(),i.observer=null),$(i.phrases),await Le(t?"pullLatest — forced":"pullLatest");else{i.phrases=Ye(document.body);var g=i.fileData.translations||{},m=(i.fileData.languages||[])[0];m&&g[m]&&ae(i.phrases,Object.keys(g[m]))}Ue()}else h&&(U(h),le(h));if(c.length===1){var v=c[0];return console.log("[loco] pullLatest: "+v.status+(v.timestamp?" (timestamp: "+v.timestamp+")":"")),v}var p=c.filter(function(u){return u.status==="updated"}).map(function(u){return u.url}),s=c.filter(function(u){return u.status==="current"}).map(function(u){return u.url});return console.log("[loco] pullLatest: "+p.length+" updated, "+s.length+" current"),{status:f?"updated":"current",results:c}}).catch(function(c){return console.warn("[loco] pullLatest() failed:",c),{status:"error",reason:c.message}})}};return window.Loco=O,Object.defineProperty(O,"_state",{value:i,writable:!1,enumerable:!1,configurable:!1}),O}(); From bdcbac586ec8bb8f5418169c83aef6a443376cfe Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Thu, 23 Jul 2026 19:09:45 -0400 Subject: [PATCH 08/12] chore: remove committed playwright mcp snapshots from PR --- .../page-2026-07-17T21-38-43-083Z.yml | 84 -------- .../page-2026-07-17T21-38-59-514Z.yml | 192 ------------------ .../page-2026-07-17T21-40-43-850Z.yml | 1 - .../page-2026-07-23T17-13-29-827Z.yml | 0 .../page-2026-07-23T18-14-39-145Z.yml | 0 .../page-2026-07-23T18-38-53-595Z.yml | 58 ------ 6 files changed, 335 deletions(-) delete mode 100644 .playwright-mcp/page-2026-07-17T21-38-43-083Z.yml delete mode 100644 .playwright-mcp/page-2026-07-17T21-38-59-514Z.yml delete mode 100644 .playwright-mcp/page-2026-07-17T21-40-43-850Z.yml delete mode 100644 .playwright-mcp/page-2026-07-23T17-13-29-827Z.yml delete mode 100644 .playwright-mcp/page-2026-07-23T18-14-39-145Z.yml delete mode 100644 .playwright-mcp/page-2026-07-23T18-38-53-595Z.yml diff --git a/.playwright-mcp/page-2026-07-17T21-38-43-083Z.yml b/.playwright-mcp/page-2026-07-17T21-38-43-083Z.yml deleted file mode 100644 index b62342655..000000000 --- a/.playwright-mcp/page-2026-07-17T21-38-43-083Z.yml +++ /dev/null @@ -1,84 +0,0 @@ -- generic [ref=e3]: - - banner [ref=e7]: - - heading "Storybook" [level=1] [ref=e8] - - generic [ref=e12]: - - generic [ref=e13]: - - generic [ref=e14]: - - link "Skip to content" [ref=e15] [cursor=pointer]: - - /url: "#storybook-preview-wrapper" - - link "MIE Web UI • BlueHive" [ref=e17] [cursor=pointer]: - - /url: https://github.com/mieweb/ui - - img "MIE Web UI • BlueHive" - - switch "Settings" [ref=e18] [cursor=pointer] - - generic [ref=e24]: - - generic [ref=e26] [cursor=pointer]: - - button "Open onboarding guide" [ref=e30]: - - strong [ref=e34]: Get started - - generic [ref=e35]: - - button "Collapse onboarding checklist" [expanded] [ref=e36] - - button "28% completed" [ref=e39]: - - generic [ref=e45]: 28% - - list [ref=e48]: - - listitem [ref=e49]: - - button "Open onboarding guide for Render your first component" [ref=e50] [cursor=pointer]: - - generic [ref=e54]: Render your first component - - listitem [ref=e56]: - - button "Open onboarding guide for Change a story with Controls" [ref=e57] [cursor=pointer]: - - generic [ref=e61]: Change a story with Controls - - listitem [ref=e63]: - - button "Open onboarding guide for Install Vitest addon" [ref=e64] [cursor=pointer]: - - generic [ref=e68]: Install Vitest addon - - generic [ref=e70]: Search for components - - search [ref=e71]: - - combobox [ref=e72]: - - searchbox "Search for components" [ref=e73] - - code: - - generic: ⌘ - - text: K - - button "Tag filters" [ref=e75] [cursor=pointer] - - button "Create a new story" [ref=e78] [cursor=pointer] - - navigation [ref=e82]: - - heading "Stories" [level=2] [ref=e83] - - generic [ref=e85]: - - link [ref=e87] [cursor=pointer]: - - /url: /?path=/docs/introduction--docs - - link [ref=e92] [cursor=pointer]: - - /url: /?path=/docs/branding--docs - - generic [ref=e96]: - - button "Collapse" [expanded] [ref=e97] [cursor=pointer]: Foundations - - button "Expand all" [ref=e101] [cursor=pointer] - - link [ref=e105] [cursor=pointer]: - - /url: /?path=/story/foundations-colors--colors - - button [ref=e110] [cursor=pointer] - - button [ref=e118] [cursor=pointer] - - button [ref=e126] [cursor=pointer] - - button [ref=e134] [cursor=pointer] - - button [ref=e142] [cursor=pointer] - - generic [ref=e149]: - - button "Collapse" [expanded] [ref=e150] [cursor=pointer]: Product - - button "Expand all" [ref=e154] [cursor=pointer] - - button [ref=e158] [cursor=pointer] - - button [ref=e166] [cursor=pointer] - - generic [ref=e173]: - - button "Collapse" [expanded] [ref=e174] [cursor=pointer]: Components - - button "Expand all" [ref=e178] [cursor=pointer] - - button [ref=e182] [cursor=pointer] - - button [ref=e190] [cursor=pointer] - - button [ref=e198] [cursor=pointer] - - button [ref=e206] [cursor=pointer] - - button [ref=e214] [cursor=pointer] - - button [ref=e222] [cursor=pointer] - - button [ref=e230] [cursor=pointer] - - button [ref=e238] [cursor=pointer] - - button [ref=e246] [cursor=pointer] - - button [ref=e254] [cursor=pointer] - - button [ref=e262] [cursor=pointer] - - generic [ref=e270]: - - region [ref=e271]: - - heading "Toolbar" [level=2] [ref=e272] - - toolbar [ref=e273] - - main [ref=e274]: - - heading "Main preview area" [level=2] [ref=e275] - - generic [ref=e276]: - - progressbar "Content is loading..." [ref=e278] - - iframe [ref=e283] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-17T21-38-59-514Z.yml b/.playwright-mcp/page-2026-07-17T21-38-59-514Z.yml deleted file mode 100644 index a78984c40..000000000 --- a/.playwright-mcp/page-2026-07-17T21-38-59-514Z.yml +++ /dev/null @@ -1,192 +0,0 @@ -- generic [ref=f2e3]: - - banner [ref=f2e6]: - - heading "Storybook" [level=1] [ref=f2e7] - - generic [ref=f2e11]: - - generic [ref=f2e12]: - - generic [ref=f2e13]: - - link "Skip to content" [ref=f2e14] [cursor=pointer]: - - /url: "#storybook-preview-wrapper" - - link [ref=f2e16] [cursor=pointer]: - - /url: https://github.com/mieweb/ui - - img "MIE Web UI • BlueHive" [ref=f2e17] - - switch "Settings" [ref=f2e18] [cursor=pointer] - - generic [ref=f2e24]: - - generic [ref=f2e26] [cursor=pointer]: - - button "Open onboarding guide" [ref=f2e30]: - - strong [ref=f2e34]: Get started - - generic [ref=f2e35]: - - button "Collapse onboarding checklist" [expanded] [ref=f2e36] - - button "28% completed" [ref=f2e39]: - - generic [ref=f2e45]: 28% - - list [ref=f2e48]: - - listitem [ref=f2e49]: - - button "Open onboarding guide for Render your first component" [ref=f2e50] [cursor=pointer]: - - generic [ref=f2e54]: Render your first component - - listitem [ref=f2e56]: - - button "Open onboarding guide for Change a story with Controls" [ref=f2e57] [cursor=pointer]: - - generic [ref=f2e61]: Change a story with Controls - - listitem [ref=f2e63]: - - button "Open onboarding guide for Install Vitest addon" [ref=f2e64] [cursor=pointer]: - - generic [ref=f2e68]: Install Vitest addon - - generic [ref=f2e70]: Search for components - - search [ref=f2e71]: - - combobox [ref=f2e72]: - - searchbox "Search for components" [ref=f2e73] - - code: - - generic: ⌘ - - text: K - - button "Tag filters" [ref=f2e75] [cursor=pointer] - - button "Create a new story" [ref=f2e78] [cursor=pointer] - - navigation [ref=f2e82]: - - heading "Stories" [level=2] [ref=f2e83] - - generic [ref=f2e85]: - - link [ref=f2e87] [cursor=pointer]: - - /url: /?path=/docs/introduction--docs - - link [ref=f2e92] [cursor=pointer]: - - /url: /?path=/docs/branding--docs - - generic [ref=f2e96]: - - button "Collapse" [expanded] [ref=f2e97] [cursor=pointer]: Foundations - - button "Expand all" [ref=f2e101] [cursor=pointer] - - link [ref=f2e105] [cursor=pointer]: - - /url: /?path=/story/foundations-colors--colors - - button [ref=f2e110] [cursor=pointer] - - button [ref=f2e118] [cursor=pointer] - - button [ref=f2e126] [cursor=pointer] - - button [ref=f2e134] [cursor=pointer] - - button [ref=f2e142] [cursor=pointer] - - generic [ref=f2e149]: - - button "Collapse" [expanded] [ref=f2e150] [cursor=pointer]: Product - - button "Expand all" [ref=f2e154] [cursor=pointer] - - button [ref=f2e158] [cursor=pointer] - - button [ref=f2e166] [cursor=pointer] - - generic [ref=f2e173]: - - button "Collapse" [expanded] [ref=f2e174] [cursor=pointer]: Components - - button "Expand all" [ref=f2e178] [cursor=pointer] - - button [ref=f2e182] [cursor=pointer] - - button [expanded] [ref=f2e190] [cursor=pointer] - - button [expanded] [ref=f2e198] [cursor=pointer] - - generic [ref=f2e205]: - - link [ref=f2e206] [cursor=pointer]: - - /url: /?path=/docs/components-forms-inputs-addcontactmodal--docs - - link "Skip to content" [ref=f2e210] [cursor=pointer]: - - /url: "#storybook-preview-wrapper" - - link [ref=f2e212] [cursor=pointer]: - - /url: /?path=/story/components-forms-inputs-addcontactmodal--default - - link [ref=f2e217] [cursor=pointer]: - - /url: /?path=/story/components-forms-inputs-addcontactmodal--edit-mode - - link [ref=f2e222] [cursor=pointer]: - - /url: /?path=/story/components-forms-inputs-addcontactmodal--minimal-fields - - link [ref=f2e227] [cursor=pointer]: - - /url: /?path=/story/components-forms-inputs-addcontactmodal--with-phone-only - - link [ref=f2e232] [cursor=pointer]: - - /url: /?path=/story/components-forms-inputs-addcontactmodal--saving-state - - link [ref=f2e237] [cursor=pointer]: - - /url: /?path=/story/components-forms-inputs-addcontactmodal--with-validation-errors - - link [ref=f2e242] [cursor=pointer]: - - /url: /?path=/story/components-forms-inputs-addcontactmodal--provider-contact - - link [ref=f2e247] [cursor=pointer]: - - /url: /?path=/story/components-forms-inputs-addcontactmodal--mobile - - button [ref=f2e252] [cursor=pointer] - - button [ref=f2e260] [cursor=pointer] - - button [ref=f2e268] [cursor=pointer] - - button [ref=f2e276] [cursor=pointer] - - button [ref=f2e284] [cursor=pointer] - - button [ref=f2e292] [cursor=pointer] - - button [ref=f2e300] [cursor=pointer] - - button [ref=f2e308] [cursor=pointer] - - button [ref=f2e316] [cursor=pointer] - - button [ref=f2e324] [cursor=pointer] - - button [ref=f2e332] [cursor=pointer] - - button [ref=f2e340] [cursor=pointer] - - button [ref=f2e348] [cursor=pointer] - - button [ref=f2e356] [cursor=pointer] - - button [ref=f2e364] [cursor=pointer] - - button [ref=f2e372] [cursor=pointer] - - button [ref=f2e380] [cursor=pointer] - - button [ref=f2e388] [cursor=pointer] - - button [ref=f2e396] [cursor=pointer] - - button [ref=f2e404] [cursor=pointer] - - button [ref=f2e412] [cursor=pointer] - - button [ref=f2e420] [cursor=pointer] - - button [ref=f2e428] [cursor=pointer] - - button [ref=f2e436] [cursor=pointer] - - button [ref=f2e444] [cursor=pointer] - - button [ref=f2e452] [cursor=pointer] - - button [ref=f2e460] [cursor=pointer] - - button [ref=f2e468] [cursor=pointer] - - button [ref=f2e476] [cursor=pointer] - - button [ref=f2e484] [cursor=pointer] - - button [ref=f2e492] [cursor=pointer] - - button [ref=f2e500] [cursor=pointer] - - button [ref=f2e508] [cursor=pointer] - - button [ref=f2e516] [cursor=pointer] - - button [ref=f2e524] [cursor=pointer] - - button [ref=f2e532] [cursor=pointer] - - button [ref=f2e540] [cursor=pointer] - - button [ref=f2e548] [cursor=pointer] - - button [ref=f2e556] [cursor=pointer] - - button [ref=f2e564] [cursor=pointer] - - button [ref=f2e572] [cursor=pointer] - - button [ref=f2e580] [cursor=pointer] - - button [ref=f2e588] [cursor=pointer] - - button [ref=f2e596] [cursor=pointer] - - button [ref=f2e604] [cursor=pointer] - - button [ref=f2e612] [cursor=pointer] - - button [ref=f2e620] [cursor=pointer] - - button [ref=f2e628] [cursor=pointer] - - button [ref=f2e636] [cursor=pointer] - - button [ref=f2e644] [cursor=pointer] - - button [ref=f2e652] [cursor=pointer] - - button [ref=f2e660] [cursor=pointer] - - button [ref=f2e668] [cursor=pointer] - - generic [ref=f2e676]: - - region [ref=f2e677]: - - heading "Toolbar" [level=2] [ref=f2e678] - - toolbar [ref=f2e679]: - - switch "Outline tool" [ref=f2e681] [cursor=pointer] - - generic [ref=f2e684]: - - button "Enter full screen" [ref=f2e685] [cursor=pointer] - - button "Open in editor" [ref=f2e688] [cursor=pointer] - - main [ref=f2e692]: - - heading "Main preview area" [level=2] [ref=f2e693] - - generic [ref=f2e694]: - - progressbar "Content is loading..." [ref=f2e696] - - generic [ref=f2e697]: - - link "Skip to sidebar" [ref=f2e698] [cursor=pointer]: - - /url: "#components-forms-inputs-addcontactmodal--docs" - - iframe [ref=f2e702]: - - table [ref=f3e10]: - - rowgroup [ref=f3e11]: - - row [ref=f3e12]: - - columnheader [ref=f3e13]: Name - - columnheader [ref=f3e14]: Description - - columnheader [ref=f3e15]: Default - - columnheader [ref=f3e16]: Control - - rowgroup [ref=f3e17]: - - row [ref=f3e18]: - - cell [ref=f3e19]: propertyName* - - cell [ref=f3e20]: - - generic [ref=f3e21]: This is a short description - - generic [ref=f3e22]: summary - - cell [ref=f3e24]: - - generic [ref=f3e25]: defaultValue - - cell [ref=f3e26]: - - button [ref=f3e27]: Set string - - row [ref=f3e28]: - - cell [ref=f3e29]: propertyName* - - cell [ref=f3e30]: - - generic [ref=f3e31]: This is a short description - - generic [ref=f3e32]: summary - - cell [ref=f3e34]: - - generic [ref=f3e35]: defaultValue - - cell [ref=f3e36]: - - button [ref=f3e37]: Set string - - row [ref=f3e38]: - - cell [ref=f3e39]: propertyName* - - cell [ref=f3e40]: - - generic [ref=f3e41]: This is a short description - - generic [ref=f3e42]: summary - - cell [ref=f3e44]: - - generic [ref=f3e45]: defaultValue - - cell [ref=f3e46]: - - button [ref=f3e47]: Set string \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-17T21-40-43-850Z.yml b/.playwright-mcp/page-2026-07-17T21-40-43-850Z.yml deleted file mode 100644 index 43768622c..000000000 --- a/.playwright-mcp/page-2026-07-17T21-40-43-850Z.yml +++ /dev/null @@ -1 +0,0 @@ -- generic [active] [ref=e1]: "{\"statusCode\":500,\"error\":\"Internal Server Error\",\"message\":\"reply.sendFile is not a function\"}" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-23T17-13-29-827Z.yml b/.playwright-mcp/page-2026-07-23T17-13-29-827Z.yml deleted file mode 100644 index e69de29bb..000000000 diff --git a/.playwright-mcp/page-2026-07-23T18-14-39-145Z.yml b/.playwright-mcp/page-2026-07-23T18-14-39-145Z.yml deleted file mode 100644 index e69de29bb..000000000 diff --git a/.playwright-mcp/page-2026-07-23T18-38-53-595Z.yml b/.playwright-mcp/page-2026-07-23T18-38-53-595Z.yml deleted file mode 100644 index c50b657fb..000000000 --- a/.playwright-mcp/page-2026-07-23T18-38-53-595Z.yml +++ /dev/null @@ -1,58 +0,0 @@ -- generic [ref=e3]: - - banner [ref=e6]: - - heading "Storybook" [level=1] [ref=e7] - - generic [ref=e11]: - - generic [ref=e13]: - - link "Skip to content" [ref=e14] [cursor=pointer]: - - /url: "#storybook-preview-wrapper" - - link [ref=e16] [cursor=pointer]: - - /url: https://github.com/mieweb/ui - - img "MIE Web UI • BlueHive" [ref=e17] - - switch "Settings" [ref=e18] [cursor=pointer] - - generic [ref=e22]: Search for components - - search [ref=e23]: - - combobox [ref=e24]: - - searchbox "Search for components" [ref=e25] - - code: - - generic: ⌘ - - text: K - - button "Create a new story" [ref=e26] [cursor=pointer] - - navigation [ref=e30]: - - heading "Stories" [level=2] [ref=e31] - - generic [ref=e52]: - - region [ref=e53]: - - heading "Toolbar" [level=2] [ref=e54] - - toolbar [ref=e55]: - - generic [ref=e56]: - - button "Reload story" [ref=e57] [cursor=pointer] - - switch "Measure tool" [ref=e60] [cursor=pointer] - - switch "Outline tool" [ref=e64] [cursor=pointer] - - button "Viewport size" [ref=e67] [cursor=pointer] - - button "Vision filter" [ref=e72] [cursor=pointer] - - generic [ref=e77]: - - switch "Change zoom level" [ref=e78] [cursor=pointer]: 100% - - button "Enter full screen" [ref=e79] [cursor=pointer] - - button "Share" [ref=e82] [cursor=pointer] - - main [ref=e86]: - - heading "Main preview area" [level=2] [ref=e87] - - generic [ref=e88]: - - progressbar "Content is loading..." [ref=e90] - - generic [ref=e91]: - - link "Skip to sidebar" [ref=e92] [cursor=pointer]: - - /url: "#components-forms-inputs-addcontactmodal--i18n-translated" - - iframe [ref=e96]: - - - region [ref=e99]: - - heading "Addon panel" [level=2] [ref=e100] - - generic [ref=e101]: - - generic [ref=e102]: - - generic [ref=e103]: - - button "Move addon panel to right" [ref=e104] [cursor=pointer] - - button "Hide addon panel" [ref=e108] [cursor=pointer] - - tablist "Available addons" [ref=e114]: - - tab "Controls" [selected] [ref=e115] [cursor=pointer] - - tab "Actions" [ref=e118] [cursor=pointer] - - tab "Interactions" [ref=e121] [cursor=pointer] - - tab "Accessibility" [ref=e124] [cursor=pointer] - - tab "Code" [ref=e127] [cursor=pointer] - - tabpanel "Controls" [ref=e128] \ No newline at end of file From 573a855ce494d47b87a0aaa634fc550d81c3b8c8 Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Fri, 24 Jul 2026 15:58:15 -0400 Subject: [PATCH 09/12] feat(i18n): derive locale options from loco pack and live language API --- .storybook/preview.tsx | 180 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 173 insertions(+), 7 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 3f02c328c..94624ff1e 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -26,9 +26,131 @@ const locoScriptLoaders = new Map>(); // fully offline — no Loco server required. const LOCO_PACK_URL = '/i18n/i18n-translations.json'; const LOCO_RUNTIME_URL = '/i18n/loco.min.js'; +const LOCO_LIVE_LANG_CACHE_KEY = 'mieweb:loco:languages'; +const LOCO_LIVE_LANG_RELOAD_FLAG = 'mieweb:loco:languages:reloaded'; +const DEFAULT_LOCALE = 'en'; const locoPackLanguages: string[] = Array.isArray((locoI18nPack as { languages?: string[] }).languages) ? (locoI18nPack as { languages: string[] }).languages : []; +const locoPackLanguageNames = + (locoI18nPack as { languageNames?: Record }).languageNames || {}; + +type LocoLanguageInfo = { + code: string; + name?: string; + dir?: 'ltr' | 'rtl'; +}; + +const localeNameFallbacks: Record = { + en: 'English', + fr: 'French', + 'zh-Hans': 'Chinese (Simplified)', + 'zh-Hant': 'Chinese (Traditional)', +}; + +function parseCachedLiveLanguages(): LocoLanguageInfo[] { + if (typeof window === 'undefined') return []; + try { + const raw = window.localStorage.getItem(LOCO_LIVE_LANG_CACHE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((entry) => entry && typeof entry.code === 'string') + .map((entry) => ({ + code: String(entry.code), + name: typeof entry.name === 'string' ? entry.name : undefined, + dir: entry.dir === 'rtl' ? 'rtl' : 'ltr', + })); + } catch { + return []; + } +} + +function resolveLocaleTitle(code: string, explicitName?: string): string { + if (explicitName?.trim()) return explicitName.trim(); + + const normalized = code.trim(); + if (localeNameFallbacks[normalized]) { + return localeNameFallbacks[normalized]; + } + + if (typeof Intl !== 'undefined' && 'DisplayNames' in Intl) { + try { + const formatter = new Intl.DisplayNames([DEFAULT_LOCALE], { + type: 'language', + }); + const label = formatter.of(normalized); + if (label && label !== normalized) return label; + } catch { + // Ignore unsupported locale code formatting. + } + } + + return normalized; +} + +function buildLocaleToolbarItems(): Array<{ value: string; title: string }> { + const merged = new Map(); + merged.set(DEFAULT_LOCALE, localeNameFallbacks[DEFAULT_LOCALE] || 'English'); + + for (const code of locoPackLanguages) { + if (!code) continue; + merged.set(code, locoPackLanguageNames[code] || merged.get(code) || code); + } + + for (const liveLang of parseCachedLiveLanguages()) { + if (!liveLang.code) continue; + merged.set( + liveLang.code, + liveLang.name || locoPackLanguageNames[liveLang.code] || merged.get(liveLang.code) || liveLang.code + ); + } + + const items = Array.from(merged.entries()).map(([value, name]) => ({ + value, + title: `${resolveLocaleTitle(value, name)} (${value})`, + })); + + items.sort((a, b) => { + if (a.value === DEFAULT_LOCALE) return -1; + if (b.value === DEFAULT_LOCALE) return 1; + return a.title.localeCompare(b.title); + }); + + return items; +} + +const localeToolbarItems = buildLocaleToolbarItems(); + +async function fetchLiveLocoLanguages( + serverUrl: string, + apiKey?: string, +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (apiKey) headers['x-api-key'] = apiKey; + + const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/languages`, { + method: 'GET', + headers, + }); + if (!response.ok) { + throw new Error(`Loco languages fetch failed (${response.status})`); + } + + const payload = await response.json(); + if (!Array.isArray(payload)) return []; + + return payload + .filter((entry) => entry && typeof entry.code === 'string') + .map((entry) => ({ + code: String(entry.code), + name: typeof entry.name === 'string' ? entry.name : undefined, + dir: entry.dir === 'rtl' ? 'rtl' : 'ltr', + })); +} type LocoRuntime = { init?: (config: { apiUrl?: string; apiKey?: string; file?: string }) => Promise | unknown; @@ -369,10 +491,58 @@ const withBrand: Decorator = (Story, context) => { const withLocoLiveSync: Decorator = (Story, context) => { const locoMode = String(context.globals?.locoMode || 'package'); const locale = String(context.globals?.locale || 'en'); + const queryParams = + typeof window !== 'undefined' + ? new URLSearchParams(window.location.search) + : null; + const queryLocoServer = queryParams?.get('locoServerUrl')?.trim() || ''; + const queryLocoApiKey = queryParams?.get('locoApiKey')?.trim() || ''; const configuredServer = String(context.globals?.locoServer || '').trim(); const configuredApiKey = String(context.globals?.locoApiKey || '').trim(); - const serverUrl = configuredServer || defaultLocoServer; - const apiKey = configuredApiKey || defaultLocoApiKey || undefined; + const serverUrl = queryLocoServer || configuredServer || defaultLocoServer; + const apiKey = queryLocoApiKey || configuredApiKey || defaultLocoApiKey || undefined; + + useEffect(() => { + if (locoMode !== 'live' || isLocoDisabled || typeof window === 'undefined') { + return; + } + + let cancelled = false; + + void (async () => { + const languages = await fetchLiveLocoLanguages(serverUrl, apiKey); + if (cancelled || languages.length === 0) return; + + const stableLanguages = [...languages].sort((a, b) => + a.code.localeCompare(b.code) + ); + const nextCodes = new Set(stableLanguages.map((item) => item.code)); + const hasNewLanguage = stableLanguages.some( + (item) => !localeToolbarItems.some((localeItem) => localeItem.value === item.code) + ); + + window.localStorage.setItem( + LOCO_LIVE_LANG_CACHE_KEY, + JSON.stringify(stableLanguages) + ); + + // Storybook global toolbar options are defined statically at module load. + // Refresh once per tab when live mode discovers additional languages. + if (hasNewLanguage && typeof window.sessionStorage !== 'undefined') { + const wasReloaded = window.sessionStorage.getItem(LOCO_LIVE_LANG_RELOAD_FLAG); + if (!wasReloaded && nextCodes.size > 0) { + window.sessionStorage.setItem(LOCO_LIVE_LANG_RELOAD_FLAG, '1'); + window.location.reload(); + } + } + })().catch((error) => { + console.warn('[loco-live] Unable to fetch live language list.', error); + }); + + return () => { + cancelled = true; + }; + }, [locoMode, serverUrl, apiKey]); useEffect(() => { if (locoMode !== 'live' || isLocoDisabled) return; @@ -510,11 +680,7 @@ const preview: Preview = { description: 'Locale used by i18n integration stories', toolbar: { icon: 'globe', - items: [ - { value: 'en', title: 'English (en)' }, - { value: 'fr', title: 'French (fr)' }, - { value: 'zh-Hans', title: 'Chinese (zh-Hans)' }, - ], + items: localeToolbarItems, dynamicTitle: true, }, }, From bb34f3e9aa088e40b0917c41d26af0f3764e8d2a Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Fri, 24 Jul 2026 16:06:12 -0400 Subject: [PATCH 10/12] fix(i18n): address Copilot review feedback for Loco integration --- .storybook/preview.tsx | 2 +- scripts/sync-loco-pack.mjs | 17 +++++++++-- src/components/Badge/Badge.stories.tsx | 30 +++++++++---------- .../Text/TextI18nIntegration.stories.tsx | 14 +++++---- src/utils/i18n.test.ts | 27 +++++++++++++++++ src/utils/i18n.ts | 2 +- src/utils/loco-live.ts | 8 ++++- 7 files changed, 73 insertions(+), 27 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 94624ff1e..d2a511aeb 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -391,7 +391,7 @@ function applyBrandStyles(brand: BrandConfig, isDark: boolean) { // Default Loco configuration from environment variables. // Falls back to the shared hosted Loco instance when env values are absent. const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'https://loco.os.mieweb.org'; -const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8'; +const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || ''; const isLocoDisabled = (import.meta.env.VITE_DISABLE_LOCO as string | undefined)?.trim() === 'true'; // Appends a "View source on GitHub" link below each story, derived from the diff --git a/scripts/sync-loco-pack.mjs b/scripts/sync-loco-pack.mjs index 272ea44be..cd0e925e5 100644 --- a/scripts/sync-loco-pack.mjs +++ b/scripts/sync-loco-pack.mjs @@ -18,7 +18,7 @@ function printHelp() { Options: --server= Loco server URL (default: https://loco.os.mieweb.org) - --apiKey= Loco API key (or set LOCO_API_KEY) + --apiKey= Loco API key (or set LOCO_API_KEY / VITE_LOCO_API_KEY) --out= Output file path (default: src/i18n/i18n-translations.json) --format= Export format (default: loco — consumable by both the Loco runtime file mode and createLocoTranslator) @@ -28,6 +28,8 @@ Options: Environment variables: LOCO_SERVER_URL LOCO_API_KEY + VITE_LOCO_SERVER_URL + VITE_LOCO_API_KEY `); } @@ -37,8 +39,17 @@ async function main() { printHelp(); return; } - const server = (args.server || process.env.LOCO_SERVER_URL || 'https://loco.os.mieweb.org').replace(/\/$/, ''); - const apiKey = args.apiKey || process.env.LOCO_API_KEY || ''; + const server = ( + args.server || + process.env.LOCO_SERVER_URL || + process.env.VITE_LOCO_SERVER_URL || + 'https://loco.os.mieweb.org' + ).replace(/\/$/, ''); + const apiKey = + args.apiKey || + process.env.LOCO_API_KEY || + process.env.VITE_LOCO_API_KEY || + ''; const outPath = args.out || 'src/i18n/i18n-translations.json'; const format = args.format || 'loco'; const contextMode = args.contextMode || 'ignore'; diff --git a/src/components/Badge/Badge.stories.tsx b/src/components/Badge/Badge.stories.tsx index c235709de..598ce0cc7 100644 --- a/src/components/Badge/Badge.stories.tsx +++ b/src/components/Badge/Badge.stories.tsx @@ -102,7 +102,7 @@ type Story = StoryObj; export const Default: Story = { render: (args, context) => { const t = getTranslator(context); - return {t('ui.badge.single')}; + return {t('ui.badge.single', 'Badge')}; }, }; @@ -111,12 +111,12 @@ export const AllVariants: Story = { const t = getTranslator(context); return (
- {t('ui.badge.variants.default')} - {t('ui.badge.variants.secondary')} - {t('ui.badge.variants.success')} - {t('ui.badge.variants.warning')} - {t('ui.badge.variants.danger')} - {t('ui.badge.variants.outline')} + {t('ui.badge.variants.default', 'Default')} + {t('ui.badge.variants.secondary', 'Secondary')} + {t('ui.badge.variants.success', 'Success')} + {t('ui.badge.variants.warning', 'Warning')} + {t('ui.badge.variants.danger', 'Danger')} + {t('ui.badge.variants.outline', 'Outline')}
); }, @@ -127,9 +127,9 @@ export const AllSizes: Story = { const t = getTranslator(context); return (
- {t('ui.badge.sizes.small')} - {t('ui.badge.sizes.medium')} - {t('ui.badge.sizes.large')} + {t('ui.badge.sizes.small', 'Small')} + {t('ui.badge.sizes.medium', 'Medium')} + {t('ui.badge.sizes.large', 'Large')}
); }, @@ -144,7 +144,7 @@ export const WithIcon: Story = { {...args} icon={IconComponent ? : undefined} > - {t('ui.badge.examples.new')} + {t('ui.badge.examples.new', 'New')} ); }, @@ -159,10 +159,10 @@ export const StatusExamples: Story = { const t = getTranslator(context); return (
- {t('ui.badge.examples.active')} - {t('ui.badge.examples.pending')} - {t('ui.badge.examples.expired')} - {t('ui.badge.examples.draft')} + {t('ui.badge.examples.active', 'Active')} + {t('ui.badge.examples.pending', 'Pending')} + {t('ui.badge.examples.expired', 'Expired')} + {t('ui.badge.examples.draft', 'Draft')}
); }, diff --git a/src/components/Text/TextI18nIntegration.stories.tsx b/src/components/Text/TextI18nIntegration.stories.tsx index f3b65033f..4208782e0 100644 --- a/src/components/Text/TextI18nIntegration.stories.tsx +++ b/src/components/Text/TextI18nIntegration.stories.tsx @@ -45,20 +45,22 @@ const PackageDrivenTranslationsDemo = ({ locale }: { locale: string }) => { weight="bold" className={`transition-all duration-300 ${isLocaleChanging ? 'translate-y-[1px] opacity-60' : 'translate-y-0 opacity-100'}`} > - {t('ui.page.title')} + {t('Edit Contact', 'Edit Contact')} - {t('ui.page.subtitle')} + {t('Description', 'Description')}
- ui.actions.save => {t('ui.actions.save')} - ui.actions.cancel => {t('ui.actions.cancel')} - ui.status.ready => {t('ui.status.ready')} - ui.unknown.key => {t('ui.unknown.key')} + Add Contact => {t('Add Contact', 'Add Contact')} + Cancel => {t('Cancel', 'Cancel')} + Email => {t('Email', 'Email')} + + Missing Phrase => {t('ui.unknown.key', 'Fallback text')} +
); diff --git a/src/utils/i18n.test.ts b/src/utils/i18n.test.ts index 4a94d4c1c..67340a9ce 100644 --- a/src/utils/i18n.test.ts +++ b/src/utils/i18n.test.ts @@ -66,6 +66,33 @@ describe('loco i18n utils', () => { expect(t('ui.does.not.exist', 'Default text')).toBe('Default text'); }); + it('preserves empty-string translations without falling back', () => { + const packWithEmpty: LocoI18nPackage = { + ...samplePack, + resources: { + ...samplePack.resources, + fr: { + translation: { + ui: { + actions: { + save: '', + }, + }, + }, + }, + }, + }; + + expect( + resolveLocoTranslation(packWithEmpty, 'fr', 'ui.actions.save', 'en') + ).toBe(''); + + const t = createLocoTranslator(packWithEmpty, 'fr', { + fallbackLanguage: 'en', + }); + expect(t('ui.actions.save')).toBe(''); + }); + it('supports the native loco export format', () => { const locoPack: LocoI18nPackage = { languages: ['zh-Hans'], diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index ffd325796..d8300a178 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -93,7 +93,7 @@ export function resolveLocoTranslation( if (typeof direct === 'string') return direct; const nested = resolveDottedValue(dictionary, key); - if (nested) return nested; + if (nested !== undefined) return nested; if (fallbackLanguage && fallbackLanguage !== language) { const fallbackDictionary = getLocoDictionary(i18nPackage, fallbackLanguage); diff --git a/src/utils/loco-live.ts b/src/utils/loco-live.ts index a67047c85..d39aceb5a 100644 --- a/src/utils/loco-live.ts +++ b/src/utils/loco-live.ts @@ -30,10 +30,16 @@ function isUsefulPhrase(value: string): boolean { if (!value) return false; if (value.length < 2 || value.length > 180) return false; if (/^[\d\s.,:%+-/()]+$/.test(value)) return false; + if (/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(value)) return false; + if (/(https?:\/\/|www\.)/i.test(value)) return false; return true; } export function collectLocoKeysFromElement(root: HTMLElement): LocoKeyEntry[] { + const doc = root.ownerDocument; + const nodeFilter = doc?.defaultView?.NodeFilter; + if (!doc || !nodeFilter) return []; + const collected = new Map(); const addPhrase = (raw: string) => { @@ -43,7 +49,7 @@ export function collectLocoKeysFromElement(root: HTMLElement): LocoKeyEntry[] { collected.set(phrase, { key: phrase, context: '' }); }; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const walker = doc.createTreeWalker(root, nodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { const textNode = node as Text; From 29c39a1c4a108f1160d609ace1449c23873302e0 Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Fri, 24 Jul 2026 19:44:27 -0400 Subject: [PATCH 11/12] fix(i18n): set hosted loco defaults with local env overrides --- .storybook/preview.tsx | 237 +++++++++++++++++++++++++++++++++++------ 1 file changed, 207 insertions(+), 30 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d2a511aeb..ad0a0a4b3 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,6 +1,6 @@ /// import type { Preview, Decorator } from '@storybook/react-vite'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { addons } from 'storybook/preview-api'; import '../src/styles/base.css'; import '../src/styles/kerebron.css'; @@ -28,6 +28,8 @@ const LOCO_PACK_URL = '/i18n/i18n-translations.json'; const LOCO_RUNTIME_URL = '/i18n/loco.min.js'; const LOCO_LIVE_LANG_CACHE_KEY = 'mieweb:loco:languages'; const LOCO_LIVE_LANG_RELOAD_FLAG = 'mieweb:loco:languages:reloaded'; +const LOCO_TOOLBAR_MODE_KEY = 'mieweb:loco:toolbar-mode'; +const LOCO_RESOLVED_API_KEY_CACHE_KEY = 'mieweb:loco:resolved-api-key'; const DEFAULT_LOCALE = 'en'; const locoPackLanguages: string[] = Array.isArray((locoI18nPack as { languages?: string[] }).languages) ? (locoI18nPack as { languages: string[] }).languages @@ -35,19 +37,52 @@ const locoPackLanguages: string[] = Array.isArray((locoI18nPack as { languages?: const locoPackLanguageNames = (locoI18nPack as { languageNames?: Record }).languageNames || {}; +const localeNameFallbacks: Record = { + en: 'English', + fr: 'French', + 'zh-Hans': 'Chinese (Simplified)', + 'zh-Hant': 'Chinese (Traditional)', +}; + type LocoLanguageInfo = { code: string; name?: string; dir?: 'ltr' | 'rtl'; }; -const localeNameFallbacks: Record = { - en: 'English', - fr: 'French', - 'zh-Hans': 'Chinese (Simplified)', - 'zh-Hant': 'Chinese (Traditional)', +type LocoProjectInfo = { + id?: number; + name?: string; + api_key?: string; }; +function getCurrentLocoModeFromUrl(): 'package' | 'live' | 'disable' { + if (typeof window === 'undefined') return 'package'; + try { + const storedMode = window.sessionStorage.getItem(LOCO_TOOLBAR_MODE_KEY); + if (storedMode === 'live' || storedMode === 'disable' || storedMode === 'package') { + return storedMode; + } + } catch { + // Ignore storage access errors. + } + try { + const params = new URLSearchParams(window.location.search); + const globalsParam = params.get('globals') || ''; + const entries = globalsParam.split(';'); + for (const pair of entries) { + const [key, value] = pair.split(':'); + if (key === 'locoMode') { + if (value === 'live' || value === 'disable') return value; + return 'package'; + } + } + } catch { + // Ignore parse errors. + } + return 'package'; +} + function parseCachedLiveLanguages(): LocoLanguageInfo[] { if (typeof window === 'undefined') return []; try { @@ -90,7 +125,9 @@ function resolveLocaleTitle(code: string, explicitName?: string): string { return normalized; } -function buildLocaleToolbarItems(): Array<{ value: string; title: string }> { +function buildLocaleToolbarItems( + mode: 'package' | 'live' | 'disable' +): Array<{ value: string; title: string }> { const merged = new Map(); merged.set(DEFAULT_LOCALE, localeNameFallbacks[DEFAULT_LOCALE] || 'English'); @@ -99,12 +136,17 @@ function buildLocaleToolbarItems(): Array<{ value: string; title: string }> { merged.set(code, locoPackLanguageNames[code] || merged.get(code) || code); } - for (const liveLang of parseCachedLiveLanguages()) { - if (!liveLang.code) continue; - merged.set( - liveLang.code, - liveLang.name || locoPackLanguageNames[liveLang.code] || merged.get(liveLang.code) || liveLang.code - ); + if (mode === 'live') { + for (const liveLang of parseCachedLiveLanguages()) { + if (!liveLang.code) continue; + merged.set( + liveLang.code, + liveLang.name || + locoPackLanguageNames[liveLang.code] || + merged.get(liveLang.code) || + liveLang.code + ); + } } const items = Array.from(merged.entries()).map(([value, name]) => ({ @@ -121,7 +163,7 @@ function buildLocaleToolbarItems(): Array<{ value: string; title: string }> { return items; } -const localeToolbarItems = buildLocaleToolbarItems(); +const localeToolbarItems = buildLocaleToolbarItems(getCurrentLocoModeFromUrl()); async function fetchLiveLocoLanguages( serverUrl: string, @@ -132,7 +174,9 @@ async function fetchLiveLocoLanguages( }; if (apiKey) headers['x-api-key'] = apiKey; - const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/languages`, { + const baseUrl = serverUrl.replace(/\/$/, ''); + + const response = await fetch(`${baseUrl}/api/languages`, { method: 'GET', headers, }); @@ -143,13 +187,94 @@ async function fetchLiveLocoLanguages( const payload = await response.json(); if (!Array.isArray(payload)) return []; - return payload + const apiLanguages = payload .filter((entry) => entry && typeof entry.code === 'string') .map((entry) => ({ code: String(entry.code), name: typeof entry.name === 'string' ? entry.name : undefined, dir: entry.dir === 'rtl' ? 'rtl' : 'ltr', })); + + return apiLanguages; +} + +async function resolveLiveApiKey( + serverUrl: string, + candidateApiKey?: string, + projectName?: string, +): Promise { + const baseUrl = serverUrl.replace(/\/$/, ''); + + const isValidKey = async (key: string): Promise => { + try { + const response = await fetch(`${baseUrl}/api/project`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': key, + }, + }); + return response.ok; + } catch { + return false; + } + }; + + const normalizedCandidate = String(candidateApiKey || '').trim(); + if (normalizedCandidate && (await isValidKey(normalizedCandidate))) { + return normalizedCandidate; + } + + try { + const projectsResponse = await fetch(`${baseUrl}/api/projects`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (projectsResponse.ok) { + const payload = await projectsResponse.json(); + if (Array.isArray(payload)) { + const projects = payload as LocoProjectInfo[]; + const normalizedProjectName = String(projectName || '').trim().toLowerCase(); + + const preferred = normalizedProjectName + ? projects.find((project) => + String(project?.name || '').trim().toLowerCase() === normalizedProjectName + ) + : undefined; + + const defaultMiewebProject = !normalizedProjectName + ? projects.find( + (project) => String(project?.name || '').trim().toLowerCase() === 'miewebui' + ) + : undefined; + + const fallback = preferred || defaultMiewebProject || projects[0]; + const resolved = String(fallback?.api_key || '').trim(); + if (resolved) return resolved; + } + } + } catch { + // Ignore fallback lookup failures. + } + + try { + const projectResponse = await fetch(`${baseUrl}/api/project`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!projectResponse.ok) return undefined; + + const payload = (await projectResponse.json()) as LocoProjectInfo; + const fallbackKey = String(payload?.api_key || '').trim(); + return fallbackKey || undefined; + } catch { + return undefined; + } } type LocoRuntime = { @@ -389,9 +514,11 @@ function applyBrandStyles(brand: BrandConfig, isDark: boolean) { } // Default Loco configuration from environment variables. -// Falls back to the shared hosted Loco instance when env values are absent. +// Production fallback points at hosted Loco + miewebui project, while local +// testing can override via .env.local (VITE_LOCO_*) or URL query params. const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'https://loco.os.mieweb.org'; -const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || ''; +const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8'; +const defaultLocoProject = (import.meta.env.VITE_LOCO_PROJECT_NAME as string | undefined)?.trim() || 'miewebui'; const isLocoDisabled = (import.meta.env.VITE_DISABLE_LOCO as string | undefined)?.trim() === 'true'; // Appends a "View source on GitHub" link below each story, derived from the @@ -497,10 +624,59 @@ const withLocoLiveSync: Decorator = (Story, context) => { : null; const queryLocoServer = queryParams?.get('locoServerUrl')?.trim() || ''; const queryLocoApiKey = queryParams?.get('locoApiKey')?.trim() || ''; + const queryLocoProject = queryParams?.get('locoProject')?.trim() || ''; const configuredServer = String(context.globals?.locoServer || '').trim(); const configuredApiKey = String(context.globals?.locoApiKey || '').trim(); + const configuredProject = String(context.globals?.locoProject || '').trim(); const serverUrl = queryLocoServer || configuredServer || defaultLocoServer; const apiKey = queryLocoApiKey || configuredApiKey || defaultLocoApiKey || undefined; + const projectName = queryLocoProject || configuredProject || defaultLocoProject || undefined; + const [resolvedApiKey, setResolvedApiKey] = useState(() => { + if (typeof window === 'undefined') return apiKey; + const cached = window.sessionStorage.getItem(LOCO_RESOLVED_API_KEY_CACHE_KEY); + return cached || apiKey; + }); + const activeApiKey = resolvedApiKey || apiKey; + + useEffect(() => { + if (typeof window === 'undefined') return; + if (apiKey) { + setResolvedApiKey(apiKey); + window.sessionStorage.setItem(LOCO_RESOLVED_API_KEY_CACHE_KEY, apiKey); + return; + } + + const cached = window.sessionStorage.getItem(LOCO_RESOLVED_API_KEY_CACHE_KEY); + setResolvedApiKey(cached || undefined); + }, [apiKey, serverUrl]); + + useEffect(() => { + if (locoMode !== 'live' || isLocoDisabled || typeof window === 'undefined') return; + + let cancelled = false; + void resolveLiveApiKey(serverUrl, apiKey, projectName) + .then((key) => { + if (cancelled || !key) return; + setResolvedApiKey(key); + window.sessionStorage.setItem(LOCO_RESOLVED_API_KEY_CACHE_KEY, key); + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, [locoMode, serverUrl, apiKey, projectName]); + + useEffect(() => { + if (typeof window === 'undefined') return; + const previousMode = window.sessionStorage.getItem(LOCO_TOOLBAR_MODE_KEY); + if (previousMode && previousMode !== locoMode) { + window.sessionStorage.setItem(LOCO_TOOLBAR_MODE_KEY, locoMode); + window.location.reload(); + return; + } + window.sessionStorage.setItem(LOCO_TOOLBAR_MODE_KEY, locoMode); + }, [locoMode]); useEffect(() => { if (locoMode !== 'live' || isLocoDisabled || typeof window === 'undefined') { @@ -510,25 +686,26 @@ const withLocoLiveSync: Decorator = (Story, context) => { let cancelled = false; void (async () => { - const languages = await fetchLiveLocoLanguages(serverUrl, apiKey); + const languages = await fetchLiveLocoLanguages(serverUrl, activeApiKey); if (cancelled || languages.length === 0) return; const stableLanguages = [...languages].sort((a, b) => a.code.localeCompare(b.code) ); const nextCodes = new Set(stableLanguages.map((item) => item.code)); - const hasNewLanguage = stableLanguages.some( - (item) => !localeToolbarItems.some((localeItem) => localeItem.value === item.code) - ); + const cachedCodes = new Set(parseCachedLiveLanguages().map((item) => item.code)); + const languageSetChanged = + nextCodes.size !== cachedCodes.size || + Array.from(nextCodes).some((code) => !cachedCodes.has(code)); window.localStorage.setItem( LOCO_LIVE_LANG_CACHE_KEY, JSON.stringify(stableLanguages) ); - // Storybook global toolbar options are defined statically at module load. - // Refresh once per tab when live mode discovers additional languages. - if (hasNewLanguage && typeof window.sessionStorage !== 'undefined') { + // Storybook toolbar options are static at module load. Refresh once + // so live-mode locale options include the dashboard language list. + if (languageSetChanged && typeof window.sessionStorage !== 'undefined') { const wasReloaded = window.sessionStorage.getItem(LOCO_LIVE_LANG_RELOAD_FLAG); if (!wasReloaded && nextCodes.size > 0) { window.sessionStorage.setItem(LOCO_LIVE_LANG_RELOAD_FLAG, '1'); @@ -542,7 +719,7 @@ const withLocoLiveSync: Decorator = (Story, context) => { return () => { cancelled = true; }; - }, [locoMode, serverUrl, apiKey]); + }, [locoMode, serverUrl, activeApiKey]); useEffect(() => { if (locoMode !== 'live' || isLocoDisabled) return; @@ -561,14 +738,14 @@ const withLocoLiveSync: Decorator = (Story, context) => { serverUrl, keys, pageUrl: window.location.href, - apiKey, + apiKey: activeApiKey, }).catch((error) => { console.warn( '[loco-live-sync] Unable to post phrases to Loco. Check VITE_LOCO_SERVER_URL and VITE_LOCO_API_KEY.', error, ); }); - }, [locoMode, serverUrl, apiKey, context.id]); + }, [locoMode, serverUrl, activeApiKey, context.id]); useEffect(() => { let cancelled = false; @@ -593,7 +770,7 @@ const withLocoLiveSync: Decorator = (Story, context) => { // Nothing to undo yet — don't load the runtime just to restore originals. if (shouldRestore && !(window as any).Loco) return; - const runtime = await ensureLocoInitialized(mode, serverUrl, apiKey); + const runtime = await ensureLocoInitialized(mode, serverUrl, activeApiKey); if (!runtime || cancelled) return; if (shouldRestore) { @@ -613,7 +790,7 @@ const withLocoLiveSync: Decorator = (Story, context) => { return () => { cancelled = true; }; - }, [locoMode, locale, serverUrl, apiKey, context.id]); + }, [locoMode, locale, serverUrl, activeApiKey, context.id]); return (
From cc3e9ca72e9a80ae2edd383c6cb40f2941246561 Mon Sep 17 00:00:00 2001 From: HrithikMani Date: Fri, 24 Jul 2026 20:11:34 -0400 Subject: [PATCH 12/12] fix(i18n): prevent sticky live mode on locale-only docs URLs --- .storybook/preview.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ad0a0a4b3..d762f6b47 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -58,14 +58,6 @@ type LocoProjectInfo = { function getCurrentLocoModeFromUrl(): 'package' | 'live' | 'disable' { if (typeof window === 'undefined') return 'package'; - try { - const storedMode = window.sessionStorage.getItem(LOCO_TOOLBAR_MODE_KEY); - if (storedMode === 'live' || storedMode === 'disable' || storedMode === 'package') { - return storedMode; - } - } catch { - // Ignore storage access errors. - } try { const params = new URLSearchParams(window.location.search); const globalsParam = params.get('globals') || '';