@lovalingo/lovalingo 0.5.27 → 0.5.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { countryCodeToFlagEmoji, normalizeLocaleCode, parseLocale, resolveLocaleFlag } from "../utils/languageFlags";
3
+ describe("languageFlags", () => {
4
+ it("normalizes locale codes with case and underscore variants", () => {
5
+ expect(normalizeLocaleCode("TR")).toBe("tr");
6
+ expect(normalizeLocaleCode("tr_TR")).toBe("tr-tr");
7
+ expect(normalizeLocaleCode("tr-TR")).toBe("tr-tr");
8
+ });
9
+ it("parses language and region", () => {
10
+ expect(parseLocale("en-CA")).toEqual({ language: "en", region: "CA" });
11
+ expect(parseLocale("fr")).toEqual({ language: "fr", region: null });
12
+ });
13
+ it("returns consistent flag for TR variants", () => {
14
+ expect(resolveLocaleFlag("TR")).toBe("🇹🇷");
15
+ expect(resolveLocaleFlag("tr")).toBe("🇹🇷");
16
+ expect(resolveLocaleFlag("tr-TR")).toBe("🇹🇷");
17
+ expect(resolveLocaleFlag("tr_TR")).toBe("🇹🇷");
18
+ });
19
+ it("uses region flag when locale includes region", () => {
20
+ expect(resolveLocaleFlag("en-CA")).toBe("🇨🇦");
21
+ expect(resolveLocaleFlag("pt-BR")).toBe("🇧🇷");
22
+ });
23
+ it("uses language default region when locale has no region", () => {
24
+ expect(resolveLocaleFlag("en")).toBe("🇬🇧");
25
+ expect(resolveLocaleFlag("zh")).toBe("🇨🇳");
26
+ expect(resolveLocaleFlag("pt")).toBe("🇵🇹");
27
+ });
28
+ it("falls back to globe for unknown or invalid locale", () => {
29
+ expect(resolveLocaleFlag("zzz")).toBe("🌐");
30
+ expect(resolveLocaleFlag("")).toBe("🌐");
31
+ expect(resolveLocaleFlag(null)).toBe("🌐");
32
+ });
33
+ it("never falls back to white flag", () => {
34
+ expect(resolveLocaleFlag("zzz")).not.toBe("🏳️");
35
+ expect(resolveLocaleFlag("foo-bar-baz")).not.toBe("🏳️");
36
+ });
37
+ it("converts country code to emoji", () => {
38
+ expect(countryCodeToFlagEmoji("ca")).toBe("🇨🇦");
39
+ expect(countryCodeToFlagEmoji("PT")).toBe("🇵🇹");
40
+ expect(countryCodeToFlagEmoji("ZZZ")).toBeNull();
41
+ });
42
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mergeEntitlementsSeoEnabled } from "../utils/mergeEntitlements";
3
+ describe("mergeEntitlementsSeoEnabled", () => {
4
+ it("merges seoEnabled when provided", () => {
5
+ const entitlements = {
6
+ tier: "starter",
7
+ maxTargetLocales: 1,
8
+ allowedTargetLocales: [],
9
+ brandingRequired: true,
10
+ hreflangEnabled: false,
11
+ };
12
+ const merged = mergeEntitlementsSeoEnabled(entitlements, false);
13
+ expect(merged?.seoEnabled).toBe(false);
14
+ });
15
+ it("returns the same object when seoEnabled is not a boolean", () => {
16
+ const entitlements = {
17
+ tier: "startup",
18
+ maxTargetLocales: 3,
19
+ allowedTargetLocales: ["zh"],
20
+ brandingRequired: false,
21
+ hreflangEnabled: true,
22
+ seoEnabled: true,
23
+ };
24
+ const merged = mergeEntitlementsSeoEnabled(entitlements, null);
25
+ expect(merged).toBe(entitlements);
26
+ });
27
+ });
@@ -1,30 +1,8 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- const LANGUAGE_FLAGS = {
3
- en: '🇬🇧',
4
- de: '🇩🇪',
5
- fr: '🇫🇷',
6
- es: '🇪🇸',
7
- it: '🇮🇹',
8
- pt: '🇵🇹',
9
- nl: '🇳🇱',
10
- pl: '🇵🇱',
11
- ru: '🇷🇺',
12
- hi: '🇮🇳',
13
- ja: '🇯🇵',
14
- zh: '🇨🇳',
15
- ko: '🇰🇷',
16
- ar: '🇸🇦',
17
- hy: '🇦🇲',
18
- tr: '🇹🇷',
19
- vi: '🇻🇳',
20
- th: '🇹🇭',
21
- sv: '🇸🇪',
22
- da: '🇩🇰',
23
- no: '🇳🇴',
24
- fi: '🇫🇮',
25
- };
1
+ import React, { useState, useEffect, useMemo, useRef } from 'react';
2
+ import { normalizeLocaleCode, resolveLocaleFlag } from '../utils/languageFlags';
26
3
  export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, position = 'bottom-right', offsetY = 20, theme = 'dark', branding, }) => {
27
4
  const [isOpen, setIsOpen] = useState(false);
5
+ const [isMobile, setIsMobile] = useState(false);
28
6
  const containerRef = useRef(null);
29
7
  const isRight = position.endsWith('right');
30
8
  const isTop = position.startsWith('top');
@@ -56,11 +34,40 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
56
34
  tabHoverShadowLeft: '4px 0 16px rgba(0, 0, 0, 0.3), inset 0 0 1px rgba(255, 255, 255, 0.15)',
57
35
  panelShadow: '0 8px 24px rgba(0, 0, 0, 0.3), inset 0 0 1px rgba(255, 255, 255, 0.10)',
58
36
  };
59
- // Order locales: active first, then others
60
- const orderedLocales = [
61
- currentLocale,
62
- ...locales.filter(l => l !== currentLocale)
63
- ];
37
+ // Why: normalize locale codes for comparisons so variants like "tr_TR" and "tr-TR" are treated consistently.
38
+ const normalizedCurrentLocale = useMemo(() => normalizeLocaleCode(currentLocale), [currentLocale]);
39
+ // Why: keep active locale first while preserving caller order for the remaining locales.
40
+ const orderedLocales = useMemo(() => {
41
+ const seen = new Set();
42
+ const items = [];
43
+ const pushLocale = (value) => {
44
+ const raw = typeof value === 'string' ? value.trim() : '';
45
+ const normalized = normalizeLocaleCode(raw);
46
+ if (!raw || !normalized || seen.has(normalized))
47
+ return;
48
+ seen.add(normalized);
49
+ items.push({ raw, normalized });
50
+ };
51
+ if (normalizedCurrentLocale) {
52
+ pushLocale(currentLocale);
53
+ }
54
+ for (const locale of locales) {
55
+ const raw = typeof locale === 'string' ? locale.trim() : '';
56
+ const normalized = normalizeLocaleCode(raw);
57
+ if (!normalized || normalized === normalizedCurrentLocale)
58
+ continue;
59
+ pushLocale(raw);
60
+ }
61
+ if (items.length === 0) {
62
+ for (const locale of locales)
63
+ pushLocale(locale);
64
+ }
65
+ return items;
66
+ }, [currentLocale, locales, normalizedCurrentLocale]);
67
+ const tabFlag = useMemo(() => {
68
+ const fallbackLocale = orderedLocales[0]?.normalized || normalizedCurrentLocale;
69
+ return resolveLocaleFlag(fallbackLocale);
70
+ }, [orderedLocales, normalizedCurrentLocale]);
64
71
  // Close on outside click
65
72
  useEffect(() => {
66
73
  const handleClickOutside = (event) => {
@@ -73,6 +80,20 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
73
80
  return () => document.removeEventListener('mousedown', handleClickOutside);
74
81
  }
75
82
  }, [isOpen]);
83
+ useEffect(() => {
84
+ if (typeof window === 'undefined')
85
+ return;
86
+ const query = '(max-width: 640px)';
87
+ const media = window.matchMedia(query);
88
+ const update = () => setIsMobile(media.matches);
89
+ update();
90
+ if (typeof media.addEventListener === 'function') {
91
+ media.addEventListener('change', update);
92
+ return () => media.removeEventListener('change', update);
93
+ }
94
+ media.addListener(update);
95
+ return () => media.removeListener(update);
96
+ }, []);
76
97
  // Handle keyboard navigation
77
98
  useEffect(() => {
78
99
  const handleKeyDown = (event) => {
@@ -135,9 +156,12 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
135
156
  gap: '10px',
136
157
  boxShadow: tokens.panelShadow,
137
158
  transition: 'opacity 0.25s ease, transform 0.25s ease',
159
+ width: isMobile ? 'min(320px, calc(100vw - 88px))' : 'auto',
138
160
  };
139
161
  const localeRowStyles = {
140
- display: 'flex',
162
+ display: isMobile ? 'grid' : 'flex',
163
+ gridTemplateColumns: isMobile ? 'repeat(5, minmax(0, 1fr))' : undefined,
164
+ justifyItems: isMobile ? 'center' : undefined,
141
165
  gap: '10px',
142
166
  padding: '0 2px',
143
167
  };
@@ -159,7 +183,7 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
159
183
  alignItems: 'center',
160
184
  gap: '6px',
161
185
  };
162
- const flagButtonStyles = (locale) => ({
186
+ const flagButtonStyles = (isActive) => ({
163
187
  // Why: the panel stays mounted for the close animation, so buttons must be non-interactive while hidden.
164
188
  pointerEvents: isOpen ? 'auto' : 'none',
165
189
  width: '32px',
@@ -169,8 +193,8 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
169
193
  alignItems: 'center',
170
194
  justifyContent: 'center',
171
195
  fontSize: '20px',
172
- background: locale === currentLocale ? 'rgba(59, 130, 246, 0.2)' : 'transparent',
173
- border: locale === currentLocale ? '2px solid rgb(59, 130, 246)' : '2px solid transparent',
196
+ background: isActive ? 'rgba(59, 130, 246, 0.2)' : 'transparent',
197
+ border: isActive ? '2px solid rgb(59, 130, 246)' : '2px solid transparent',
174
198
  cursor: 'pointer',
175
199
  transition: 'transform 0.15s ease, filter 0.15s ease, background 0.15s ease',
176
200
  flexShrink: 0,
@@ -185,26 +209,29 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
185
209
  }, onMouseLeave: (e) => {
186
210
  e.currentTarget.style.background = tokens.surfaceBg;
187
211
  e.currentTarget.style.boxShadow = isRight ? tokens.tabShadowRight : tokens.tabShadowLeft;
188
- }, "aria-label": "Open language switcher", "aria-expanded": isOpen }, LANGUAGE_FLAGS[currentLocale] || '🌐'),
212
+ }, "aria-label": "Open language switcher", "aria-expanded": isOpen }, tabFlag),
189
213
  React.createElement("div", { style: panelStyles, role: "toolbar", "aria-label": "Language options" },
190
- React.createElement("div", { style: localeRowStyles }, orderedLocales.map((locale) => (React.createElement("button", { key: locale, style: flagButtonStyles(locale), onClick: (e) => {
191
- e.stopPropagation();
192
- if (locale === currentLocale) {
193
- setIsOpen(false);
194
- }
195
- else {
196
- onLocaleChange(locale);
197
- setIsOpen(false);
198
- }
199
- }, onMouseEnter: (e) => {
200
- if (locale !== currentLocale) {
201
- e.currentTarget.style.filter = 'brightness(1.3)';
202
- }
203
- e.currentTarget.style.transform = 'scale(1.1)';
204
- }, onMouseLeave: (e) => {
205
- e.currentTarget.style.filter = 'brightness(1)';
206
- e.currentTarget.style.transform = 'scale(1)';
207
- }, "aria-label": `Switch to ${locale.toUpperCase()}`, title: locale.toUpperCase(), tabIndex: isOpen ? 0 : -1 }, LANGUAGE_FLAGS[locale] || '🏳️')))),
214
+ React.createElement("div", { style: localeRowStyles }, orderedLocales.map((entry) => {
215
+ const isActive = entry.normalized === normalizedCurrentLocale;
216
+ return (React.createElement("button", { key: entry.normalized, style: flagButtonStyles(isActive), onClick: (e) => {
217
+ e.stopPropagation();
218
+ if (isActive) {
219
+ setIsOpen(false);
220
+ }
221
+ else {
222
+ onLocaleChange(entry.raw);
223
+ setIsOpen(false);
224
+ }
225
+ }, onMouseEnter: (e) => {
226
+ if (!isActive) {
227
+ e.currentTarget.style.filter = 'brightness(1.3)';
228
+ }
229
+ e.currentTarget.style.transform = 'scale(1.1)';
230
+ }, onMouseLeave: (e) => {
231
+ e.currentTarget.style.filter = 'brightness(1)';
232
+ e.currentTarget.style.transform = 'scale(1)';
233
+ }, "aria-label": `Switch to ${entry.normalized.toUpperCase()}`, title: entry.normalized.toUpperCase(), tabIndex: isOpen ? 0 : -1 }, resolveLocaleFlag(entry.normalized)));
234
+ })),
208
235
  (branding?.required || branding?.enabled) && (React.createElement("div", { style: badgeRowStyles, "aria-label": "Lovalingo branding" },
209
236
  React.createElement("a", { href: branding.href || 'https://lovalingo.com', target: "_blank", rel: "noreferrer", style: { ...badgeLinkStyles, pointerEvents: isOpen ? 'auto' : 'none' }, tabIndex: isOpen ? 0 : -1, "aria-label": "Localized by Lovalingo", title: "Localized by Lovalingo" },
210
237
  React.createElement("span", { style: {
@@ -15,6 +15,7 @@ import { useStringMissReporting } from '../hooks/provider/useStringMissReporting
15
15
  import { LanguageSwitcher } from './LanguageSwitcher';
16
16
  import { readEditParams } from './provider/editModeUtils';
17
17
  import { applySeoBundle as applySeoBundleInternal } from './provider/seoUtils';
18
+ import { mergeEntitlementsSeoEnabled } from "../utils/mergeEntitlements";
18
19
  import { useEditModeOverlay } from './provider/useEditModeOverlay';
19
20
  import { useHistoryNavigationPatch } from './provider/useHistoryNavigationPatch';
20
21
  import { detectLocaleFromLocation, setDocumentLocale } from './provider/localeUtils';
@@ -147,8 +148,9 @@ navigateRef, // For path mode routing
147
148
  const bootstrap = await apiRef.current.fetchBootstrap(locale, window.location.pathname + window.location.search);
148
149
  if (cancelled)
149
150
  return;
150
- if (bootstrap?.entitlements)
151
- setEntitlements(bootstrap.entitlements);
151
+ if (bootstrap?.entitlements) {
152
+ setEntitlements(mergeEntitlementsSeoEnabled(bootstrap.entitlements, bootstrap.seoEnabled));
153
+ }
152
154
  if (bootstrap?.loading_bg_color)
153
155
  setCachedLoadingBgColor(bootstrap.loading_bg_color);
154
156
  if (bootstrap?.entitlements?.brandingRequired) {
@@ -188,6 +190,7 @@ navigateRef, // For path mode routing
188
190
  enhancedPathConfig,
189
191
  mode,
190
192
  autoApplyRules,
193
+ seoProp: seo,
191
194
  isSeoActive,
192
195
  applySeoBundle,
193
196
  setEntitlements,
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { resolveCanonicalHref } from "../seoUtils";
3
+ describe("resolveCanonicalHref", () => {
4
+ test("prefers alternates canonical when present", () => {
5
+ expect(resolveCanonicalHref({ canonical_url: "https://example.com/en" }, { canonical: "https://example.com/fr" })).toBe("https://example.com/fr");
6
+ });
7
+ test("falls back to seo.canonical_url", () => {
8
+ expect(resolveCanonicalHref({ canonical_url: "https://example.com/en" }, null)).toBe("https://example.com/en");
9
+ });
10
+ test("returns empty when none", () => {
11
+ expect(resolveCanonicalHref({}, null)).toBe("");
12
+ });
13
+ });
@@ -3,3 +3,6 @@ export declare function applySeoBundle(bundle: {
3
3
  alternates?: any;
4
4
  jsonld?: any;
5
5
  } | null, hreflangEnabled: boolean): void;
6
+ export declare function resolveCanonicalHref(seo: Record<string, unknown>, alternates: {
7
+ canonical?: string;
8
+ } | null | undefined): string;
@@ -66,11 +66,7 @@ export function applySeoBundle(bundle, hreflangEnabled) {
66
66
  const twitterImageAlt = getString(seo.twitter_image_alt);
67
67
  if (twitterImageAlt)
68
68
  setOrCreateMeta({ name: 'twitter:image:alt' }, twitterImageAlt);
69
- const canonicalHref = typeof seo.canonical_url === 'string' && seo.canonical_url.trim()
70
- ? seo.canonical_url.trim()
71
- : typeof alternates.canonical === 'string' && alternates.canonical.trim()
72
- ? alternates.canonical.trim()
73
- : '';
69
+ const canonicalHref = resolveCanonicalHref(seo, alternates);
74
70
  const languages = alternates.languages && typeof alternates.languages === 'object' ? alternates.languages : {};
75
71
  const hasAnyAlternate = Boolean(alternates.xDefault) || Object.values(languages).some(Boolean);
76
72
  if (!canonicalHref && !(hreflangEnabled && hasAnyAlternate))
@@ -111,3 +107,12 @@ export function applySeoBundle(bundle, hreflangEnabled) {
111
107
  // ignore SEO errors
112
108
  }
113
109
  }
110
+ export function resolveCanonicalHref(seo, alternates) {
111
+ const alt = alternates && typeof alternates === "object" ? alternates : null;
112
+ // Why: when alternates exist, canonical must be self-referencing for the active locale.
113
+ if (alt && typeof alt.canonical === "string" && alt.canonical.trim())
114
+ return alt.canonical.trim();
115
+ if (typeof seo?.canonical_url === "string" && seo.canonical_url.trim())
116
+ return seo.canonical_url.trim();
117
+ return "";
118
+ }
@@ -12,6 +12,7 @@ type UseBundleLoadingOptions = {
12
12
  enhancedPathConfig: PathNormalizationConfig;
13
13
  mode: "dom" | undefined;
14
14
  autoApplyRules: boolean;
15
+ seoProp: boolean;
15
16
  isSeoActive: () => boolean;
16
17
  applySeoBundle: (bundle: {
17
18
  seo?: Record<string, unknown>;
@@ -24,7 +25,7 @@ type UseBundleLoadingOptions = {
24
25
  setCachedLoadingBgColor: (color: string | null | undefined) => void;
25
26
  getCachedLoadingBgColor: () => string;
26
27
  };
27
- export declare function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routing, allLocales, nonLocalizedPaths, enhancedPathConfig, mode, autoApplyRules, isSeoActive, applySeoBundle, setEntitlements, setBrandingEnabled, setCachedBrandingEnabled, setCachedLoadingBgColor, getCachedLoadingBgColor, }: UseBundleLoadingOptions): {
28
+ export declare function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routing, allLocales, nonLocalizedPaths, enhancedPathConfig, mode, autoApplyRules, seoProp, isSeoActive, applySeoBundle, setEntitlements, setBrandingEnabled, setCachedBrandingEnabled, setCachedLoadingBgColor, getCachedLoadingBgColor, }: UseBundleLoadingOptions): {
28
29
  isLoading: boolean;
29
30
  isNavigatingRef: React.MutableRefObject<boolean>;
30
31
  loadData: (targetLocale: string, previousLocale?: string) => Promise<void>;
@@ -4,10 +4,11 @@ import { errorDebug, logDebug } from "../../utils/logger";
4
4
  import { isNonLocalizedPath, stripLocalePrefix } from "../../utils/nonLocalizedPaths";
5
5
  import { processPath } from "../../utils/pathNormalizer";
6
6
  import { applyActiveTranslations, restoreDom, setActiveTranslations, setMarkerEngineExclusions } from "../../utils/markerEngine";
7
+ import { mergeEntitlementsSeoEnabled } from "../../utils/mergeEntitlements";
7
8
  import { useDomRules } from "./useDomRules";
8
9
  import { PREHIDE_FAILSAFE_MS, usePrehide } from "./usePrehide";
9
10
  const CRITICAL_CACHE_PREFIX = "Lovalingo_critical_v0_3";
10
- export function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routing, allLocales, nonLocalizedPaths, enhancedPathConfig, mode, autoApplyRules, isSeoActive, applySeoBundle, setEntitlements, setBrandingEnabled, setCachedBrandingEnabled, setCachedLoadingBgColor, getCachedLoadingBgColor, }) {
11
+ export function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routing, allLocales, nonLocalizedPaths, enhancedPathConfig, mode, autoApplyRules, seoProp, isSeoActive, applySeoBundle, setEntitlements, setBrandingEnabled, setCachedBrandingEnabled, setCachedLoadingBgColor, getCachedLoadingBgColor, }) {
11
12
  const [isLoading, setIsLoading] = useState(false);
12
13
  const retryTimeoutRef = useRef(null);
13
14
  const loadingFailsafeTimeoutRef = useRef(null);
@@ -208,7 +209,8 @@ export function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routin
208
209
  revealedViaCachedCritical = true;
209
210
  }
210
211
  const bootstrap = await apiRef.current.fetchBootstrap(targetLocale, currentPath);
211
- const nextEntitlements = bootstrap?.entitlements || apiRef.current.getEntitlements();
212
+ const entitlementsBase = bootstrap?.entitlements || apiRef.current.getEntitlements();
213
+ const nextEntitlements = mergeEntitlementsSeoEnabled(entitlementsBase, bootstrap?.seoEnabled);
212
214
  if (nextEntitlements)
213
215
  setEntitlements(nextEntitlements);
214
216
  if (bootstrap?.loading_bg_color) {
@@ -260,7 +262,17 @@ export function useBundleLoading({ apiRef, resolvedApiKey, defaultLocale, routin
260
262
  : await apiRef.current.fetchDomRules(targetLocale);
261
263
  setAndApplyDomRules(cacheKey, domRules);
262
264
  }
263
- if (isSeoActive() && bootstrap) {
265
+ let seoActiveForBootstrap = isSeoActive();
266
+ if (seoProp === false) {
267
+ seoActiveForBootstrap = false;
268
+ }
269
+ else if (typeof bootstrap?.seoEnabled === "boolean") {
270
+ seoActiveForBootstrap = bootstrap.seoEnabled !== false;
271
+ }
272
+ else if (typeof nextEntitlements?.seoEnabled === "boolean") {
273
+ seoActiveForBootstrap = nextEntitlements.seoEnabled !== false;
274
+ }
275
+ if (seoActiveForBootstrap && bootstrap) {
264
276
  const hreflangEnabled = Boolean((bootstrap.entitlements || nextEntitlements)?.hreflangEnabled);
265
277
  applySeoBundle({ seo: bootstrap.seo, alternates: bootstrap.alternates, jsonld: bootstrap.jsonld }, hreflangEnabled);
266
278
  }
@@ -0,0 +1,7 @@
1
+ export declare function normalizeLocaleCode(locale: unknown): string;
2
+ export declare function parseLocale(locale: unknown): {
3
+ language: string;
4
+ region: string | null;
5
+ };
6
+ export declare function countryCodeToFlagEmoji(countryCode: unknown): string | null;
7
+ export declare function resolveLocaleFlag(locale: unknown): string;
@@ -0,0 +1,90 @@
1
+ const EXACT_LOCALE_FLAG_OVERRIDES = {
2
+ en: "🇬🇧",
3
+ ar: "🇸🇦",
4
+ zh: "🇨🇳",
5
+ fa: "🇮🇷",
6
+ he: "🇮🇱",
7
+ };
8
+ const LANGUAGE_DEFAULT_REGION = {
9
+ ar: "SA",
10
+ bn: "BD",
11
+ cs: "CZ",
12
+ da: "DK",
13
+ de: "DE",
14
+ el: "GR",
15
+ en: "GB",
16
+ es: "ES",
17
+ fa: "IR",
18
+ fi: "FI",
19
+ fr: "FR",
20
+ he: "IL",
21
+ hi: "IN",
22
+ hu: "HU",
23
+ hy: "AM",
24
+ id: "ID",
25
+ it: "IT",
26
+ ja: "JP",
27
+ ko: "KR",
28
+ nl: "NL",
29
+ no: "NO",
30
+ pl: "PL",
31
+ pt: "PT",
32
+ ro: "RO",
33
+ ru: "RU",
34
+ sk: "SK",
35
+ sv: "SE",
36
+ th: "TH",
37
+ tr: "TR",
38
+ uk: "UA",
39
+ vi: "VN",
40
+ yo: "NG",
41
+ zh: "CN",
42
+ };
43
+ export function normalizeLocaleCode(locale) {
44
+ if (typeof locale !== "string")
45
+ return "";
46
+ return locale.trim().replace(/_/g, "-").toLowerCase();
47
+ }
48
+ export function parseLocale(locale) {
49
+ const normalized = normalizeLocaleCode(locale);
50
+ if (!normalized)
51
+ return { language: "", region: null };
52
+ const parts = normalized.split("-").filter(Boolean);
53
+ const language = parts[0] || "";
54
+ const regionPart = parts.find((part, index) => index > 0 && /^[a-z]{2}$/.test(part));
55
+ const region = regionPart ? regionPart.toUpperCase() : null;
56
+ return { language, region };
57
+ }
58
+ export function countryCodeToFlagEmoji(countryCode) {
59
+ if (typeof countryCode !== "string")
60
+ return null;
61
+ const normalized = countryCode.trim().toUpperCase();
62
+ if (!/^[A-Z]{2}$/.test(normalized))
63
+ return null;
64
+ const first = normalized.charCodeAt(0) + 127397;
65
+ const second = normalized.charCodeAt(1) + 127397;
66
+ return String.fromCodePoint(first, second);
67
+ }
68
+ export function resolveLocaleFlag(locale) {
69
+ const normalized = normalizeLocaleCode(locale);
70
+ if (!normalized)
71
+ return "🌐";
72
+ const exact = EXACT_LOCALE_FLAG_OVERRIDES[normalized];
73
+ if (exact)
74
+ return exact;
75
+ const { language, region } = parseLocale(normalized);
76
+ if (!language)
77
+ return "🌐";
78
+ if (region) {
79
+ const regionFlag = countryCodeToFlagEmoji(region);
80
+ if (regionFlag)
81
+ return regionFlag;
82
+ }
83
+ const defaultRegion = LANGUAGE_DEFAULT_REGION[language];
84
+ if (defaultRegion) {
85
+ const defaultFlag = countryCodeToFlagEmoji(defaultRegion);
86
+ if (defaultFlag)
87
+ return defaultFlag;
88
+ }
89
+ return "🌐";
90
+ }
@@ -0,0 +1,2 @@
1
+ import type { ProjectEntitlements } from "./apiTypes";
2
+ export declare function mergeEntitlementsSeoEnabled(entitlements: ProjectEntitlements | null | undefined, seoEnabled: unknown): ProjectEntitlements | null;
@@ -0,0 +1,7 @@
1
+ export function mergeEntitlementsSeoEnabled(entitlements, seoEnabled) {
2
+ if (!entitlements)
3
+ return null;
4
+ if (typeof seoEnabled !== "boolean")
5
+ return entitlements;
6
+ return { ...entitlements, seoEnabled };
7
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.5.16";
1
+ export declare const VERSION = "0.5.28";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.5.16";
1
+ export const VERSION = "0.5.28";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovalingo/lovalingo",
3
- "version": "0.5.27",
3
+ "version": "0.5.28",
4
4
  "description": "React translation runtime with i18n routing, deterministic bundles + DOM rules, and zero-flash rendering.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",