feat: default locale Russian, geo determines language for other countries
- localization-svc: defaultLocale ru, resolveLocale only by geo - web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru - countryToLocale: default ru when no country or unknown country Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
27
services/localization-svc/src/index.ts
Normal file
27
services/localization-svc/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import localeRouter from './routes/locale.js';
|
||||
import translationsRouter from './routes/translations.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: true }));
|
||||
const PORT = parseInt(process.env.PORT ?? '3016', 10);
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
app.use('/api', localeRouter);
|
||||
app.use('/api', translationsRouter);
|
||||
|
||||
app.get('/health', (_, res) =>
|
||||
res.json({ status: 'ok', service: 'localization-svc' }),
|
||||
);
|
||||
app.get('/ready', (_, res) => res.json({ status: 'ready' }));
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`localization-svc listening on :${PORT}`);
|
||||
console.log(` Locale (GET): /api/locale`);
|
||||
console.log(` Locale (POST): /api/locale (with client data in body)`);
|
||||
console.log(` Translations: /api/translations/:locale`);
|
||||
console.log(` Supported locales: /api/locales`);
|
||||
});
|
||||
80
services/localization-svc/src/lib/countryToLocale.ts
Normal file
80
services/localization-svc/src/lib/countryToLocale.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Маппинг ISO 3166-1 alpha-2 country code → BCP 47 locale (language).
|
||||
* Приоритет: основные языки по странам.
|
||||
*/
|
||||
export const countryToLocale: Record<string, string> = {
|
||||
RU: 'ru',
|
||||
UA: 'uk',
|
||||
BY: 'be',
|
||||
KZ: 'kk',
|
||||
US: 'en',
|
||||
GB: 'en',
|
||||
AU: 'en',
|
||||
CA: 'en',
|
||||
DE: 'de',
|
||||
AT: 'de',
|
||||
CH: 'de',
|
||||
FR: 'fr',
|
||||
BE: 'fr',
|
||||
ES: 'es',
|
||||
MX: 'es',
|
||||
AR: 'es',
|
||||
IT: 'it',
|
||||
PT: 'pt',
|
||||
BR: 'pt',
|
||||
PL: 'pl',
|
||||
NL: 'nl',
|
||||
SE: 'sv',
|
||||
NO: 'nb',
|
||||
DK: 'da',
|
||||
FI: 'fi',
|
||||
CZ: 'cs',
|
||||
SK: 'sk',
|
||||
HU: 'hu',
|
||||
RO: 'ro',
|
||||
BG: 'bg',
|
||||
HR: 'hr',
|
||||
RS: 'sr',
|
||||
GR: 'el',
|
||||
TR: 'tr',
|
||||
JP: 'ja',
|
||||
CN: 'zh',
|
||||
TW: 'zh-TW',
|
||||
KR: 'ko',
|
||||
IN: 'hi',
|
||||
TH: 'th',
|
||||
VI: 'vi',
|
||||
ID: 'id',
|
||||
MY: 'ms',
|
||||
SA: 'ar',
|
||||
AE: 'ar',
|
||||
EG: 'ar',
|
||||
IL: 'he',
|
||||
IR: 'fa',
|
||||
};
|
||||
|
||||
/** Поддерживаемые локали с fallback. */
|
||||
export const supportedLocales = new Set<string>([
|
||||
'en', 'ru', 'uk', 'de', 'fr', 'es', 'it', 'pt', 'pl', 'zh', 'zh-TW', 'ja', 'ko',
|
||||
]);
|
||||
|
||||
/** По умолчанию русский; при геопозиции другой страны — язык этой страны */
|
||||
const defaultLocale = 'ru';
|
||||
|
||||
export function resolveLocaleFromCountry(countryCode: string | null | undefined): string {
|
||||
if (!countryCode) return defaultLocale;
|
||||
return countryToLocale[countryCode.toUpperCase()] ?? defaultLocale;
|
||||
}
|
||||
|
||||
export function normalizeAcceptLanguage(header: string | undefined): string | null {
|
||||
if (!header || !header.trim()) return null;
|
||||
const first = header.split(',')[0]?.trim();
|
||||
if (!first) return null;
|
||||
const [lang] = first.split(';');
|
||||
const code = lang?.trim().toLowerCase().split('-')[0];
|
||||
return code ?? null;
|
||||
}
|
||||
|
||||
export function getDefaultLocale(): string {
|
||||
return defaultLocale;
|
||||
}
|
||||
31
services/localization-svc/src/lib/geoClient.ts
Normal file
31
services/localization-svc/src/lib/geoClient.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { GeoDeviceContext } from '../types.js';
|
||||
|
||||
const GEO_DEVICE_URL =
|
||||
process.env.GEO_DEVICE_SVC_URL ?? process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:3015';
|
||||
|
||||
export async function fetchGeoContext(
|
||||
headers: Record<string, string>,
|
||||
body?: unknown,
|
||||
): Promise<GeoDeviceContext | null> {
|
||||
try {
|
||||
const url = `${GEO_DEVICE_URL}/api/context`;
|
||||
const options: RequestInit = {
|
||||
method: body ? 'POST' : 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'user-agent': headers['user-agent'] ?? '',
|
||||
'accept-language': headers['accept-language'] ?? '',
|
||||
'x-forwarded-for': headers['x-forwarded-for'] ?? '',
|
||||
'x-real-ip': headers['x-real-ip'] ?? '',
|
||||
},
|
||||
};
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as GeoDeviceContext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
40
services/localization-svc/src/lib/resolveLocale.ts
Normal file
40
services/localization-svc/src/lib/resolveLocale.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { GeoDeviceContext, LocalizationContext } from '../types.js';
|
||||
import {
|
||||
resolveLocaleFromCountry,
|
||||
getDefaultLocale,
|
||||
} from './countryToLocale.js';
|
||||
|
||||
/**
|
||||
* Определяет locale на основе geo-контекста.
|
||||
* По умолчанию — русский. Только если геопозиция из другой страны — язык этой страны.
|
||||
*/
|
||||
export function resolveLocale(ctx: GeoDeviceContext | null): LocalizationContext {
|
||||
const fallback: LocalizationContext = {
|
||||
locale: getDefaultLocale(),
|
||||
language: getDefaultLocale(),
|
||||
region: null,
|
||||
countryCode: null,
|
||||
timezone: null,
|
||||
source: 'fallback',
|
||||
};
|
||||
|
||||
if (!ctx) return fallback;
|
||||
|
||||
const geo = ctx.geo;
|
||||
|
||||
// Геопозиция (countryCode) — если есть, используем язык страны
|
||||
if (geo?.countryCode) {
|
||||
const locale = resolveLocaleFromCountry(geo.countryCode);
|
||||
return {
|
||||
locale,
|
||||
language: locale,
|
||||
region: geo.region ?? null,
|
||||
countryCode: geo.countryCode,
|
||||
timezone: geo.timezone ?? null,
|
||||
source: 'geo',
|
||||
};
|
||||
}
|
||||
|
||||
// Нет гео — по умолчанию русский
|
||||
return fallback;
|
||||
}
|
||||
41
services/localization-svc/src/routes/locale.ts
Normal file
41
services/localization-svc/src/routes/locale.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { fetchGeoContext } from '../lib/geoClient.js';
|
||||
import { resolveLocale } from '../lib/resolveLocale.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function getHeaders(req: Request): Record<string, string> {
|
||||
return {
|
||||
'user-agent': req.headers['user-agent'] ?? '',
|
||||
'accept-language': req.headers['accept-language'] ?? '',
|
||||
'x-forwarded-for': (req.headers['x-forwarded-for'] as string) ?? '',
|
||||
'x-real-ip': (req.headers['x-real-ip'] as string) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/locale', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const headers = getHeaders(req);
|
||||
const geoContext = await fetchGeoContext(headers);
|
||||
const localization = resolveLocale(geoContext);
|
||||
res.json(localization);
|
||||
} catch (err) {
|
||||
console.error('Error resolving locale:', err);
|
||||
res.status(500).json({ error: 'Failed to resolve locale' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/locale', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
const headers = getHeaders(req);
|
||||
const geoContext = await fetchGeoContext(headers, body);
|
||||
const localization = resolveLocale(geoContext);
|
||||
res.json(localization);
|
||||
} catch (err) {
|
||||
console.error('Error resolving locale:', err);
|
||||
res.status(500).json({ error: 'Failed to resolve locale' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
34
services/localization-svc/src/routes/translations.ts
Normal file
34
services/localization-svc/src/routes/translations.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import {
|
||||
getTranslations,
|
||||
getSupportedLocales,
|
||||
} from '../translations/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/translations/:locale', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { locale } = req.params;
|
||||
if (!locale) {
|
||||
res.status(400).json({ error: 'Locale is required' });
|
||||
return;
|
||||
}
|
||||
const translations = getTranslations(locale);
|
||||
res.json(translations);
|
||||
} catch (err) {
|
||||
console.error('Error fetching translations:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch translations' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/locales', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const locales = getSupportedLocales();
|
||||
res.json({ locales });
|
||||
} catch (err) {
|
||||
console.error('Error listing locales:', err);
|
||||
res.status(500).json({ error: 'Failed to list locales' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
175
services/localization-svc/src/translations/index.ts
Normal file
175
services/localization-svc/src/translations/index.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
export type TranslationKeys = keyof typeof en;
|
||||
|
||||
const en = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Search...',
|
||||
'empty.subtitle': 'Research begins here.',
|
||||
'input.askAnything': 'Ask anything...',
|
||||
'input.askFollowUp': 'Ask a follow-up',
|
||||
'chat.send': 'Send',
|
||||
'chat.newChat': 'New Chat',
|
||||
'chat.suggestions': 'Suggestions',
|
||||
'discover.title': 'Discover',
|
||||
'discover.region': 'Region',
|
||||
'weather.title': 'Weather',
|
||||
'weather.feelsLike': 'Feels like',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Error',
|
||||
'common.retry': 'Retry',
|
||||
'chat.related': 'Related',
|
||||
'chat.searchImages': 'Search images',
|
||||
'chat.searchVideos': 'Search videos',
|
||||
'chat.video': 'Video',
|
||||
'chat.uploadedFile': 'Uploaded File',
|
||||
'chat.viewMore': 'View {count} more',
|
||||
'chat.sources': 'Sources',
|
||||
} as const;
|
||||
|
||||
const ru = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Поиск...',
|
||||
'empty.subtitle': 'Исследование начинается здесь.',
|
||||
'input.askAnything': 'Спросите что угодно...',
|
||||
'input.askFollowUp': 'Задайте уточняющий вопрос',
|
||||
'chat.send': 'Отправить',
|
||||
'chat.newChat': 'Новый чат',
|
||||
'chat.suggestions': 'Подсказки',
|
||||
'discover.title': 'Обзор',
|
||||
'discover.region': 'Регион',
|
||||
'weather.title': 'Погода',
|
||||
'weather.feelsLike': 'Ощущается как',
|
||||
'common.loading': 'Загрузка...',
|
||||
'common.error': 'Ошибка',
|
||||
'common.retry': 'Повторить',
|
||||
'chat.related': 'Похожие',
|
||||
'chat.searchImages': 'Поиск изображений',
|
||||
'chat.searchVideos': 'Поиск видео',
|
||||
'chat.video': 'Видео',
|
||||
'chat.uploadedFile': 'Загруженный файл',
|
||||
'chat.viewMore': 'Ещё {count}',
|
||||
'chat.sources': 'Источники',
|
||||
} as const;
|
||||
|
||||
const de = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Suchen...',
|
||||
'empty.subtitle': 'Die Recherche beginnt hier.',
|
||||
'input.askAnything': 'Fragen Sie alles...',
|
||||
'input.askFollowUp': 'Folgefrage stellen',
|
||||
'chat.send': 'Senden',
|
||||
'chat.newChat': 'Neuer Chat',
|
||||
'chat.suggestions': 'Vorschläge',
|
||||
'discover.title': 'Entdecken',
|
||||
'discover.region': 'Region',
|
||||
'weather.title': 'Wetter',
|
||||
'weather.feelsLike': 'Gefühlt wie',
|
||||
'common.loading': 'Laden...',
|
||||
'common.error': 'Fehler',
|
||||
'common.retry': 'Wiederholen',
|
||||
'chat.related': 'Ähnlich',
|
||||
'chat.searchImages': 'Bilder suchen',
|
||||
'chat.searchVideos': 'Videos suchen',
|
||||
'chat.video': 'Video',
|
||||
'chat.uploadedFile': 'Hochgeladene Datei',
|
||||
'chat.viewMore': '{count} weitere',
|
||||
'chat.sources': 'Quellen',
|
||||
} as const;
|
||||
|
||||
const fr = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Rechercher...',
|
||||
'empty.subtitle': 'La recherche commence ici.',
|
||||
'input.askAnything': 'Posez une question...',
|
||||
'input.askFollowUp': 'Poser une question de suivi',
|
||||
'chat.send': 'Envoyer',
|
||||
'chat.newChat': 'Nouveau chat',
|
||||
'chat.suggestions': 'Suggestions',
|
||||
'discover.title': 'Découvrir',
|
||||
'discover.region': 'Région',
|
||||
'weather.title': 'Météo',
|
||||
'weather.feelsLike': 'Ressenti',
|
||||
'common.loading': 'Chargement...',
|
||||
'common.error': 'Erreur',
|
||||
'common.retry': 'Réessayer',
|
||||
'chat.related': 'Similaires',
|
||||
'chat.searchImages': 'Rechercher des images',
|
||||
'chat.searchVideos': 'Rechercher des vidéos',
|
||||
'chat.video': 'Vidéo',
|
||||
'chat.uploadedFile': 'Fichier téléchargé',
|
||||
'chat.viewMore': '{count} de plus',
|
||||
'chat.sources': 'Sources',
|
||||
} as const;
|
||||
|
||||
const es = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Buscar...',
|
||||
'empty.subtitle': 'La investigación comienza aquí.',
|
||||
'input.askAnything': 'Pregunte lo que sea...',
|
||||
'input.askFollowUp': 'Hacer una pregunta de seguimiento',
|
||||
'chat.send': 'Enviar',
|
||||
'chat.newChat': 'Nuevo chat',
|
||||
'chat.suggestions': 'Sugerencias',
|
||||
'discover.title': 'Descubrir',
|
||||
'discover.region': 'Región',
|
||||
'weather.title': 'Clima',
|
||||
'weather.feelsLike': 'Sensación térmica',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.error': 'Error',
|
||||
'common.retry': 'Reintentar',
|
||||
'chat.related': 'Relacionado',
|
||||
'chat.searchImages': 'Buscar imágenes',
|
||||
'chat.searchVideos': 'Buscar videos',
|
||||
'chat.video': 'Video',
|
||||
'chat.uploadedFile': 'Archivo subido',
|
||||
'chat.viewMore': '{count} más',
|
||||
'chat.sources': 'Fuentes',
|
||||
} as const;
|
||||
|
||||
const uk = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Пошук...',
|
||||
'empty.subtitle': 'Дослідження починається тут.',
|
||||
'input.askAnything': 'Запитайте що завгодно...',
|
||||
'input.askFollowUp': 'Задайте уточнююче питання',
|
||||
'chat.send': 'Надіслати',
|
||||
'chat.newChat': 'Новий чат',
|
||||
'chat.suggestions': 'Підказки',
|
||||
'discover.title': 'Огляд',
|
||||
'discover.region': 'Регіон',
|
||||
'weather.title': 'Погода',
|
||||
'weather.feelsLike': 'Відчувається як',
|
||||
'common.loading': 'Завантаження...',
|
||||
'common.error': 'Помилка',
|
||||
'common.retry': 'Повторити',
|
||||
'chat.related': 'Повʼязані',
|
||||
'chat.searchImages': 'Пошук зображень',
|
||||
'chat.searchVideos': 'Пошук відео',
|
||||
'chat.video': 'Відео',
|
||||
'chat.uploadedFile': 'Завантажений файл',
|
||||
'chat.viewMore': 'Ще {count}',
|
||||
'chat.sources': 'Джерела',
|
||||
} as const;
|
||||
|
||||
export const translations: Record<string, Record<TranslationKeys, string>> = {
|
||||
en,
|
||||
ru,
|
||||
de,
|
||||
fr,
|
||||
es,
|
||||
uk,
|
||||
};
|
||||
|
||||
/** По умолчанию русский; при геопозиции другой страны — язык этой страны */
|
||||
const defaultLocale = 'ru';
|
||||
|
||||
export function getTranslations(locale: string): Record<TranslationKeys, string> {
|
||||
const base = locale.split('-')[0]?.toLowerCase() ?? defaultLocale;
|
||||
return (translations[base] ?? translations[defaultLocale]) as Record<
|
||||
TranslationKeys,
|
||||
string
|
||||
>;
|
||||
}
|
||||
|
||||
export function getSupportedLocales(): string[] {
|
||||
return Object.keys(translations);
|
||||
}
|
||||
34
services/localization-svc/src/types.ts
Normal file
34
services/localization-svc/src/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface GeoLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
region: string;
|
||||
city: string;
|
||||
timezone: string;
|
||||
source: 'ip' | 'client' | 'unknown';
|
||||
}
|
||||
|
||||
export interface GeoDeviceContext {
|
||||
geo: GeoLocation | null;
|
||||
device: Record<string, unknown>;
|
||||
browser: Record<string, unknown>;
|
||||
os: Record<string, unknown>;
|
||||
client: {
|
||||
language?: string;
|
||||
languages?: string[];
|
||||
};
|
||||
ip?: string;
|
||||
userAgent: string;
|
||||
acceptLanguage?: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
export interface LocalizationContext {
|
||||
locale: string;
|
||||
language: string;
|
||||
region: string | null;
|
||||
countryCode: string | null;
|
||||
timezone: string | null;
|
||||
source: 'geo' | 'accept-language' | 'client' | 'fallback';
|
||||
}
|
||||
Reference in New Issue
Block a user