@lovalingo/lovalingo 0.5.25 → 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.
- package/dist/__tests__/languageFlags.test.d.ts +1 -0
- package/dist/__tests__/languageFlags.test.js +42 -0
- package/dist/__tests__/mergeEntitlements.test.d.ts +1 -0
- package/dist/__tests__/mergeEntitlements.test.js +27 -0
- package/dist/components/LanguageSwitcher.js +80 -53
- package/dist/components/LovalingoProvider.js +18 -473
- package/dist/components/provider/__tests__/seoUtils.test.d.ts +1 -0
- package/dist/components/provider/__tests__/seoUtils.test.js +13 -0
- package/dist/components/provider/editModeUtils.d.ts +6 -0
- package/dist/components/provider/editModeUtils.js +59 -0
- package/dist/components/provider/localeUtils.d.ts +8 -0
- package/dist/components/provider/localeUtils.js +46 -0
- package/dist/components/provider/providerConstants.d.ts +12 -0
- package/dist/components/provider/providerConstants.js +11 -0
- package/dist/components/provider/seoUtils.d.ts +8 -0
- package/dist/components/provider/seoUtils.js +118 -0
- package/dist/components/provider/useEditModeOverlay.d.ts +7 -0
- package/dist/components/provider/useEditModeOverlay.js +134 -0
- package/dist/components/provider/useHistoryNavigationPatch.d.ts +3 -0
- package/dist/components/provider/useHistoryNavigationPatch.js +47 -0
- package/dist/components/provider/useProviderCache.d.ts +12 -0
- package/dist/components/provider/useProviderCache.js +82 -0
- package/dist/hooks/provider/useBundleLoading.d.ts +2 -1
- package/dist/hooks/provider/useBundleLoading.js +15 -3
- package/dist/utils/api.d.ts +3 -78
- package/dist/utils/api.js +1 -53
- package/dist/utils/apiTypes.d.ts +78 -0
- package/dist/utils/apiTypes.js +1 -0
- package/dist/utils/apiUtils.d.ts +4 -0
- package/dist/utils/apiUtils.js +54 -0
- package/dist/utils/languageFlags.d.ts +7 -0
- package/dist/utils/languageFlags.js +90 -0
- package/dist/utils/markerEngine.d.ts +8 -66
- package/dist/utils/markerEngine.js +19 -703
- package/dist/utils/markerEngineApply.d.ts +3 -0
- package/dist/utils/markerEngineApply.js +136 -0
- package/dist/utils/markerEngineConstants.d.ts +10 -0
- package/dist/utils/markerEngineConstants.js +12 -0
- package/dist/utils/markerEngineCritical.d.ts +2 -0
- package/dist/utils/markerEngineCritical.js +98 -0
- package/dist/utils/markerEngineDomUtils.d.ts +8 -0
- package/dist/utils/markerEngineDomUtils.js +74 -0
- package/dist/utils/markerEngineFilters.d.ts +2 -0
- package/dist/utils/markerEngineFilters.js +26 -0
- package/dist/utils/markerEngineMisses.d.ts +5 -0
- package/dist/utils/markerEngineMisses.js +81 -0
- package/dist/utils/markerEngineOriginals.d.ts +5 -0
- package/dist/utils/markerEngineOriginals.js +29 -0
- package/dist/utils/markerEngineScan.d.ts +5 -0
- package/dist/utils/markerEngineScan.js +162 -0
- package/dist/utils/markerEngineState.d.ts +4 -0
- package/dist/utils/markerEngineState.js +14 -0
- package/dist/utils/markerEngineStats.d.ts +3 -0
- package/dist/utils/markerEngineStats.js +28 -0
- package/dist/utils/markerEngineTranslations.d.ts +3 -0
- package/dist/utils/markerEngineTranslations.js +49 -0
- package/dist/utils/markerEngineTypes.d.ts +62 -0
- package/dist/utils/markerEngineTypes.js +1 -0
- package/dist/utils/markerEngineViewport.d.ts +2 -0
- package/dist/utils/markerEngineViewport.js +27 -0
- package/dist/utils/mergeEntitlements.d.ts +2 -0
- package/dist/utils/mergeEntitlements.js +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/utils/translator.d.ts +0 -80
- package/dist/utils/translator.js +0 -802
|
@@ -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
|
-
|
|
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
|
-
//
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
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 = (
|
|
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:
|
|
173
|
-
border:
|
|
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 },
|
|
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((
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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: {
|