Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 177 additions & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,69 @@ import { ozwellBrand } from '../src/brands/ozwell';
import { wagglelineBrand } from '../src/brands/waggleline';
import { webchartBrand } from '../src/brands/webchart';
import type { BrandConfig } from '../src/brands/types';
import { collectLocoKeysFromElement, postLocoTextnodes } from '../src/utils/loco-live';

const postedLiveSyncSignatures = new Set<string>();
const locoScriptLoaders = new Map<string, Promise<void>>();
const locoLiveInitKeys = new Set<string>();

type LocoRuntime = {
init?: (config: { apiUrl: string; apiKey?: string }) => Promise<unknown> | unknown;
apply?: (lang: string) => Promise<unknown> | unknown;
restore?: () => Promise<unknown> | unknown;
};

async function ensureLocoRuntimeLoaded(serverUrl: string): Promise<LocoRuntime | null> {
if (typeof window === 'undefined') return null;

const runtime = (window as any).Loco as LocoRuntime | undefined;
if (runtime?.init) return runtime;

const serverBase = serverUrl.replace(/\/$/, '');
const scriptId = `loco-runtime-${serverBase}`;

let loader = locoScriptLoaders.get(scriptId);
if (!loader) {
loader = new Promise<void>((resolve, reject) => {
const existing = document.getElementById(scriptId) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error('Failed to load Loco runtime script.')), { once: true });
return;
}

const script = document.createElement('script');
script.id = scriptId;
script.src = `${serverBase}/cdn/loco.js`;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Unable to load ${script.src}`));
document.head.appendChild(script);
});
locoScriptLoaders.set(scriptId, loader);
}

await loader;
return ((window as any).Loco as LocoRuntime | undefined) ?? null;
}

async function ensureLocoLiveInitialized(serverUrl: string, apiKey?: string): Promise<LocoRuntime | null> {
const runtime = await ensureLocoRuntimeLoaded(serverUrl);
if (!runtime?.init) return runtime;

const serverBase = serverUrl.replace(/\/$/, '');
const initKey = `${serverBase}|${apiKey || ''}`;
if (locoLiveInitKeys.has(initKey)) return runtime;

await Promise.resolve(
runtime.init({
apiUrl: serverBase,
apiKey,
}),
);
locoLiveInitKeys.add(initKey);
return runtime;
}

// Map of available brands
const brands: Record<string, BrandConfig> = {
Expand Down Expand Up @@ -243,11 +306,98 @@ const withBrand: Decorator = (Story, context) => {
);
};

const withLocoLiveSync: Decorator = (Story, context) => {
const locoMode = String(context.globals?.locoMode || 'package');
const locale = String(context.globals?.locale || 'en');
const configuredServer = String(context.globals?.locoServer || '').trim();
const configuredApiKey = String(context.globals?.locoApiKey || '').trim();
const serverFromEnv =
(import.meta.env?.VITE_LOCO_SERVER_URL as string | undefined)?.trim() ||
'http://localhost:6101';
const serverUrl = configuredServer || serverFromEnv;
const apiKey =
configuredApiKey ||
(import.meta.env?.VITE_LOCO_API_KEY as string | undefined)?.trim() ||
undefined;

useEffect(() => {
if (locoMode !== 'live') return;

const root = document.querySelector('[data-loco-scan-root="true"]') as HTMLElement | null;
if (!root) return;

const keys = collectLocoKeysFromElement(root);
if (keys.length === 0) return;

const signature = `${context.id}:${serverUrl}:${keys.map((entry) => entry.key).join('|')}`;
if (postedLiveSyncSignatures.has(signature)) return;
postedLiveSyncSignatures.add(signature);

void postLocoTextnodes({
serverUrl,
keys,
pageUrl: window.location.href,
apiKey,
}).catch((error) => {
console.warn(
'[loco-live-sync] Unable to post phrases to Loco. Check VITE_LOCO_SERVER_URL and VITE_LOCO_API_KEY.',
error,
);
});
}, [locoMode, serverUrl, apiKey, context.id]);

useEffect(() => {
let cancelled = false;

// If switching back to package mode, undo live runtime translations.
if (locoMode !== 'live') {
const runtime = (window as any).Loco as LocoRuntime | undefined;
if (runtime?.restore) {
void Promise.resolve(runtime.restore()).catch(() => undefined);
}
return;
}

void (async () => {
const runtime = await ensureLocoLiveInitialized(serverUrl, apiKey);
if (!runtime || cancelled) return;

// Keep English as the baseline original text in Storybook.
if (locale === 'en') {
if (runtime.restore) {
await Promise.resolve(runtime.restore());
}
return;
}

if (runtime.apply) {
await Promise.resolve(runtime.apply(locale));
}
})().catch((error) => {
console.warn('[loco-live-sync] Unable to apply live locale with Loco runtime.', error);
});

return () => {
cancelled = true;
};
}, [locoMode, locale, serverUrl, apiKey, context.id]);

return (
<div data-loco-scan-root="true">
<Story />
</div>
);
};

const preview: Preview = {
initialGlobals: {
brand: 'bluehive',
theme: 'light',
density: 'standard',
locale: 'en',
locoMode: 'package',
locoServer: 'http://localhost:6101',
locoApiKey: '82b6c1a44ec247dcb6c96fe0',
},
Comment on lines 392 to 401
globalTypes: {
brand: {
Expand Down Expand Up @@ -292,6 +442,32 @@ const preview: Preview = {
dynamicTitle: true,
},
},
locale: {
name: 'Locale',
description: 'Locale used by i18n integration stories',
toolbar: {
icon: 'globe',
items: [
{ value: 'en', title: 'English (en)' },
{ value: 'fr', title: 'French (fr)' },
{ value: 'zh-Hans', title: 'Chinese (zh-Hans)' },
],
dynamicTitle: true,
},
},
locoMode: {
name: 'Loco i18n',
description:
'Use Loco i18n package for preview or Loco Sync Text to post discovered phrases to Loco pending list.',
toolbar: {
icon: 'transfer',
items: [
{ value: 'package', title: 'Loco i18n' },
{ value: 'live', title: 'Loco Sync Text' },
],
dynamicTitle: true,
},
},
},
parameters: {
a11y: {
Expand Down Expand Up @@ -344,7 +520,7 @@ const preview: Preview = {
},
},
},
decorators: [withGitHubSource, withBrand],
decorators: [withGitHubSource, withBrand, withLocoLiveSync],
};

export default preview;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@
"test:watch": "vitest",
"prestorybook": "npm run build:esheet",
"storybook": "storybook dev -p 6006",
"loco:pack:sync": "node scripts/sync-loco-pack.mjs",
"build-storybook": "npm run build:esheet && storybook build",
"test:coverage": "vitest run --coverage",
"playwright:install": "playwright install --with-deps",
Expand Down
33 changes: 6 additions & 27 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 72 additions & 0 deletions scripts/sync-loco-pack.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';

function parseArgs(argv) {
const options = {};
for (const raw of argv) {
if (!raw.startsWith('--')) continue;
const [key, value] = raw.slice(2).split('=');
if (!key) continue;
options[key] = value ?? 'true';
}
return options;
}

function printHelp() {
console.log(`Usage: node scripts/sync-loco-pack.mjs [options]

Options:
--server=<url> Loco server URL (default: http://localhost:6101)
--apiKey=<key> Loco API key (or set LOCO_API_KEY)
--out=<path> Output file path (default: src/i18n/loco-sample-pack.json)
--format=<name> Export format (default: i18next-nested)
--contextMode=<mode> Context mode (default: ignore)
--help Show this message

Environment variables:
LOCO_SERVER_URL
LOCO_API_KEY
`);
}

async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help === 'true') {
printHelp();
return;
}
const server = (args.server || process.env.LOCO_SERVER_URL || 'http://localhost:6101').replace(/\/$/, '');
const apiKey = args.apiKey || process.env.LOCO_API_KEY || '';
const outPath = args.out || 'src/i18n/loco-sample-pack.json';
const format = args.format || 'i18next-nested';
const contextMode = args.contextMode || 'ignore';

const exportUrl = new URL(`${server}/api/export`);
exportUrl.searchParams.set('format', format);
exportUrl.searchParams.set('contextMode', contextMode);

const headers = {};
if (apiKey) {
headers['x-api-key'] = apiKey;
}

console.log(`[loco-sync] Fetching ${exportUrl.toString()}`);
const response = await fetch(exportUrl, { headers });
if (!response.ok) {
const body = await response.text();
throw new Error(`Loco export failed (${response.status}): ${body}`);
}

const payload = await response.json();
const output = `${JSON.stringify(payload, null, 2)}\n`;
const absoluteOut = path.resolve(process.cwd(), outPath);
await writeFile(absoluteOut, output, 'utf8');

console.log(`[loco-sync] Wrote package to ${absoluteOut}`);
}

main().catch((error) => {
console.error('[loco-sync] Failed:', error.message);
process.exit(1);
});
Loading
Loading