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 4003b8ad3..d762f6b47 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'; @@ -14,8 +14,362 @@ 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 { CodeLookup } from '../src/components/CodeLookup'; -import { CodeLookupProvider } from '../src/components/CodeLookup/context'; +import { collectLocoKeysFromElement, postLocoTextnodes } from '../src/utils/loco-live'; +import locoI18nPack from '../src/i18n/i18n-translations.json'; + +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. 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 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 + : []; +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'; +}; + +type LocoProjectInfo = { + id?: number; + name?: string; + api_key?: string; +}; + +function getCurrentLocoModeFromUrl(): 'package' | 'live' | 'disable' { + if (typeof window === 'undefined') return 'package'; + 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 { + 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( + mode: 'package' | 'live' | 'disable' +): 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); + } + + 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]) => ({ + 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(getCurrentLocoModeFromUrl()); + +async function fetchLiveLocoLanguages( + serverUrl: string, + apiKey?: string, +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (apiKey) headers['x-api-key'] = apiKey; + + const baseUrl = serverUrl.replace(/\/$/, ''); + + const response = await fetch(`${baseUrl}/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 []; + + 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 = { + 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( + scriptUrl: string +): Promise { + if (typeof window === 'undefined') return null; + + const runtime = (window as any).Loco as LocoRuntime | undefined; + if (runtime?.init) return runtime; + + const scriptId = `loco-runtime-${scriptUrl}`; + + 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 = scriptUrl; + 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 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; + } + + if (locoInitPromise) return locoInitPromise; + + locoInitPromise = (async () => { + // 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') { + // 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 const brands: Record = { @@ -151,6 +505,14 @@ function applyBrandStyles(brand: BrandConfig, isDark: boolean) { document.head.appendChild(styleTag); } +// Default Loco configuration from environment variables. +// 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() || '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 // story file's absolute path on disk (context.parameters.fileName). const withGitHubSource: Decorator = (Story, context) => { @@ -245,16 +607,187 @@ const withBrand: Decorator = (Story, context) => { ); }; -// Provides an ambient CodeLookup so the healthcare components' default (no -// explicit `codeLookup` / `renderCodeSearch` prop) demonstrates offline coded -// search. Stories that inject their own config still win (explicit overrides -// context); pass `codeLookup={false}` in a story to demo the plain-text opt-out. -const withCodeLookup: Decorator = (Story, context) => { - const locale = (context.globals.locale as string) || 'en'; +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 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') { + return; + } + + let cancelled = false; + + void (async () => { + 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 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 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'); + window.location.reload(); + } + } + })().catch((error) => { + console.warn('[loco-live] Unable to fetch live language list.', error); + }); + + return () => { + cancelled = true; + }; + }, [locoMode, serverUrl, activeApiKey]); + + useEffect(() => { + if (locoMode !== 'live' || isLocoDisabled) 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: 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, activeApiKey, context.id]); + + useEffect(() => { + let cancelled = false; + + // 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); + } + return; + } + + void (async () => { + 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, activeApiKey); + if (!runtime || cancelled) return; + + if (shouldRestore) { + if (runtime.restore) { + await Promise.resolve(runtime.restore()); + } + return; + } + + if (runtime.apply) { + await Promise.resolve(runtime.apply(locale)); + } + })().catch((error) => { + console.warn(`[loco] Unable to apply locale "${locale}" in ${locoMode} mode.`, error); + }); + + return () => { + cancelled = true; + }; + }, [locoMode, locale, serverUrl, activeApiKey, context.id]); + return ( - +
- +
); }; @@ -264,6 +797,9 @@ const preview: Preview = { theme: 'light', density: 'standard', locale: 'en', + locoMode: 'package', + locoServer: defaultLocoServer, + locoApiKey: defaultLocoApiKey, }, globalTypes: { brand: { @@ -309,13 +845,24 @@ const preview: Preview = { }, }, locale: { - name: 'Language', - description: 'Locale for locale-aware components (e.g. CodeLookup shards)', + name: 'Locale', + description: 'Locale used by i18n integration stories', toolbar: { icon: 'globe', + items: localeToolbarItems, + dynamicTitle: true, + }, + }, + locoMode: { + name: 'Loco i18n', + description: + '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: 'en', title: '🇺🇸 English' }, - { value: 'es', title: '🇪🇸 Español (sample)' }, + { value: 'package', title: 'Loco i18n' }, + { value: 'live', title: 'Loco Sync Text' }, + { value: 'disable', title: 'Disable' }, ], dynamicTitle: true, }, @@ -376,7 +923,7 @@ const preview: Preview = { }, }, }, - decorators: [withGitHubSource, withBrand, withCodeLookup], + decorators: [withGitHubSource, withBrand, withLocoLiveSync], }; export default preview; 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 afa15eb86..f822c92f0 100644 --- a/package.json +++ b/package.json @@ -222,6 +222,7 @@ "test:watch": "vitest", "prestorybook": "npm run build:esheet", "storybook": "storybook dev -p 6006", + "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/pnpm-lock.yaml b/pnpm-lock.yaml index 15374c9d2..35d17170f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,7 +310,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 @@ -1135,10 +1135,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==} @@ -5920,6 +5916,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'} @@ -7007,28 +7006,6 @@ snapshots: remark-math: 6.0.0 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 @@ -12648,6 +12625,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..cd0e925e5 --- /dev/null +++ b/scripts/sync-loco-pack.mjs @@ -0,0 +1,84 @@ +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: https://loco.os.mieweb.org) + --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) + --contextMode= Context mode (default: ignore) + --help Show this message + +Environment variables: + LOCO_SERVER_URL + LOCO_API_KEY + VITE_LOCO_SERVER_URL + VITE_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 || + 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'; + + 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..3a2ebac48 --- /dev/null +++ b/src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx @@ -0,0 +1,139 @@ +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/i18n-translations.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; + +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]); + + const t = createLocoTranslator(locoSamplePack, locale, { + fallbackLanguage: 'en', + }); + + return ( +
+ + + {t('AddContactModal')} + + + {t('Edit Contact')} + + + + + + + } 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 ef3e6f922..598ce0cc7 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/i18n-translations.json'; +import { createLocoTranslator } from '../../utils/i18n'; import { CheckIcon, AlertCircleIcon, @@ -43,6 +45,13 @@ 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 +100,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', 'Badge')}; }, }; export const AllVariants: Story = { - render: () => ( -
- Default - Secondary - Success - Warning - Danger - Outline -
- ), + render: (_, context) => { + const t = getTranslator(context); + return ( +
+ {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')} +
+ ); + }, }; export const AllSizes: Story = { - render: () => ( -
- Small - Medium - Large -
- ), + render: (_, context) => { + const t = getTranslator(context); + return ( +
+ {t('ui.badge.sizes.small', 'Small')} + {t('ui.badge.sizes.medium', 'Medium')} + {t('ui.badge.sizes.large', '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', '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', 'Active')} + {t('ui.badge.examples.pending', 'Pending')} + {t('ui.badge.examples.expired', 'Expired')} + {t('ui.badge.examples.draft', 'Draft')} +
+ ); + }, }; diff --git a/src/components/CountBadge/CountBadge.stories.tsx b/src/components/CountBadge/CountBadge.stories.tsx index 476712400..d0364090d 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/i18n-translations.json'; +import { createLocoTranslator } from '../../utils/i18n'; import { CheckCircleIcon, AlertCircleIcon, @@ -37,6 +39,13 @@ 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 +92,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 +223,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..4208782e0 --- /dev/null +++ b/src/components/Text/TextI18nIntegration.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useEffect, useState } from 'react'; +import { Text } from './Text'; +import locoSamplePack from '../../i18n/i18n-translations.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; + +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]); + + const t = createLocoTranslator(locoSamplePack, locale, { + fallbackLanguage: 'en', + }); + + return ( +
+ + Active Locale: {locale} + + + {t('Edit Contact', 'Edit Contact')} + + + {t('Description', 'Description')} + + +
+ Add Contact => {t('Add Contact', 'Add Contact')} + Cancel => {t('Cancel', 'Cancel')} + Email => {t('Email', 'Email')} + + Missing Phrase => {t('ui.unknown.key', 'Fallback text')} + +
+
+ ); +}; + +export const PackageDrivenTranslations: Story = { + render: (_, context) => ( + + ), +}; diff --git a/src/i18n/i18n-translations.json b/src/i18n/i18n-translations.json new file mode 100644 index 000000000..1c8c9d096 --- /dev/null +++ b/src/i18n/i18n-translations.json @@ -0,0 +1,528 @@ +{ + "languages": [ + "zh-Hans" + ], + "languageNames": { + "zh-Hans": "Chinese (Simplified)" + }, + "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}} 中所述设置自定义环境变量。" + } + ] + }, + "glossary": {}, + "aria": [], + "timestamp": 1784842843881 +} 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}(); diff --git a/src/utils/i18n.test.ts b/src/utils/i18n.test.ts new file mode 100644 index 000000000..67340a9ce --- /dev/null +++ b/src/utils/i18n.test.ts @@ -0,0 +1,115 @@ +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'); + }); + + 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'], + 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 new file mode 100644 index 000000000..d8300a178 --- /dev/null +++ b/src/utils/i18n.ts @@ -0,0 +1,125 @@ +export type LocoI18nDictionary = Record; + +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; + /** 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; +}; + +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 { + // i18next-nested export shape + const resource = asObject(i18nPackage.resources?.[language]); + if (Object.keys(resource).length > 0) { + 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 +): string | undefined { + const dictionary = getLocoDictionary(i18nPackage, language); + + const direct = dictionary[key]; + if (typeof direct === 'string') return direct; + + const nested = resolveDottedValue(dictionary, key); + if (nested !== undefined) 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..d39aceb5a --- /dev/null +++ b/src/utils/loco-live.ts @@ -0,0 +1,115 @@ +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; + 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) => { + const phrase = normalizeText(raw); + if (!isUsefulPhrase(phrase)) return; + if (collected.has(phrase)) return; + collected.set(phrase, { key: phrase, context: '' }); + }; + + const walker = doc.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; +}