/**
* i18n Detector - Detects and extracts localized fields from data structures
* Supports auto-detection of multilingual fields in the format:
* { fieldName: { locale: value, locale: value } }
*/
export interface LocalizedField {
fieldName: string;
locales: Record<string, any>;
}
export interface DetectionResult {
hasLocalizations: boolean;
defaultLocale: string;
localizedFields: LocalizedField[];
baseData: Record<string, any>;
localizations: Array<{
locale: string;
data: Record<string, any>;
}>;
}
/**
* List of common locale codes to help detect localization structures
*/
const COMMON_LOCALES = [
'en',
'es',
'fr',
'de',
'it',
'pt',
'nl',
'ca',
'eu',
'gl',
'zh',
'ja',
'ko',
'ru',
'ar',
'hi',
'en-US',
'en-GB',
'es-ES',
'es-MX',
'pt-BR',
'pt-PT',
'zh-CN',
'zh-TW',
'fr-FR',
'fr-CA',
'de-DE',
'de-AT',
'de-CH',
'it-IT',
'nl-NL',
'nl-BE',
'ca-ES',
'eu-ES',
'gl-ES',
'ru-RU',
];
/**
* Check if a value looks like a locale code
*/
function isLocaleCode(key: string): boolean {
return COMMON_LOCALES.includes(key) || /^[a-z]{2}(-[A-Z]{2})?$/.test(key);
}
/**
* Check if an object looks like a localization structure
* { es: "value", en: "value" } or { es: { ...object }, en: { ...object } }
*/
function isLocalizationStructure(value: any): boolean {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
const keys = Object.keys(value);
if (keys.length === 0) return false;
// At least 2 keys that look like locales = likely a localization structure
// Or if it has at least 1 key that's a locale code
const localeKeys = keys.filter(isLocaleCode);
return localeKeys.length >= keys.length * 0.5; // At least 50% of keys are locales
}
/**
* Detect localized fields in the data and extract them
* Returns base data with non-localized fields and separate localization data
*/
export function detectLocalizations(
data: Record<string, any>,
defaultLocale = 'es'
): DetectionResult {
const localizedFields: LocalizedField[] = [];
const baseData: Record<string, any> = {};
const localizationMap: Record<string, Record<string, any>> = {};
// First pass: identify localized fields and separate base data
for (const [fieldName, fieldValue] of Object.entries(data)) {
if (isLocalizationStructure(fieldValue)) {
// This is a localized field
const locales = fieldValue as Record<string, any>;
localizedFields.push({ fieldName, locales });
// Add default locale value to base data
if (locales[defaultLocale] !== undefined) {
baseData[fieldName] = locales[defaultLocale];
} else {
// If default locale not found, use first available locale
const firstLocale = Object.keys(locales)[0];
if (firstLocale) {
baseData[fieldName] = locales[firstLocale];
}
}
// Initialize localization map for all locales
for (const [locale, value] of Object.entries(locales)) {
if (!localizationMap[locale]) {
localizationMap[locale] = {};
}
if (locale !== defaultLocale) {
localizationMap[locale][fieldName] = value;
}
}
} else {
// Non-localized field
baseData[fieldName] = fieldValue;
}
}
// Build localization array from the map
const localizations = Object.entries(localizationMap).map(([locale, data]) => ({
locale,
data,
}));
const hasLocalizations = localizedFields.length > 0;
return {
hasLocalizations,
defaultLocale,
localizedFields,
baseData,
localizations,
};
}
/**
* Check if available locales match the detected localization fields
* Returns warnings if locales are detected but not configured in Strapi
*/
export function validateLocalizationsAgainstAvailable(
detection: DetectionResult,
availableLocales: string[]
): {
valid: boolean;
warnings: string[];
} {
const warnings: string[] = [];
for (const localization of detection.localizations) {
if (!availableLocales.includes(localization.locale)) {
warnings.push(
`Locale "${localization.locale}" is not configured in Strapi. Available locales: ${availableLocales.join(', ')}`
);
}
}
return {
valid: warnings.length === 0,
warnings,
};
}