diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 06aa7d8e..e70b2034 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 387d91e5..905d0b7d 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 3b18c258..198d9784 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 00000000..9726e3d9 --- /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 00000000..52952cc7 --- /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 ef3e6f92..fbfecbdf 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 47671240..6b497682 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 00000000..9c52fb22 --- /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 00000000..1c0f9704 --- /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 00000000..c9cc139a --- /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 00000000..0103d54e --- /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 3b541d1b..82a7bcf9 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 00000000..9b64ad94 --- /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; +}