@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.
Files changed (67) hide show
  1. package/dist/__tests__/languageFlags.test.d.ts +1 -0
  2. package/dist/__tests__/languageFlags.test.js +42 -0
  3. package/dist/__tests__/mergeEntitlements.test.d.ts +1 -0
  4. package/dist/__tests__/mergeEntitlements.test.js +27 -0
  5. package/dist/components/LanguageSwitcher.js +80 -53
  6. package/dist/components/LovalingoProvider.js +18 -473
  7. package/dist/components/provider/__tests__/seoUtils.test.d.ts +1 -0
  8. package/dist/components/provider/__tests__/seoUtils.test.js +13 -0
  9. package/dist/components/provider/editModeUtils.d.ts +6 -0
  10. package/dist/components/provider/editModeUtils.js +59 -0
  11. package/dist/components/provider/localeUtils.d.ts +8 -0
  12. package/dist/components/provider/localeUtils.js +46 -0
  13. package/dist/components/provider/providerConstants.d.ts +12 -0
  14. package/dist/components/provider/providerConstants.js +11 -0
  15. package/dist/components/provider/seoUtils.d.ts +8 -0
  16. package/dist/components/provider/seoUtils.js +118 -0
  17. package/dist/components/provider/useEditModeOverlay.d.ts +7 -0
  18. package/dist/components/provider/useEditModeOverlay.js +134 -0
  19. package/dist/components/provider/useHistoryNavigationPatch.d.ts +3 -0
  20. package/dist/components/provider/useHistoryNavigationPatch.js +47 -0
  21. package/dist/components/provider/useProviderCache.d.ts +12 -0
  22. package/dist/components/provider/useProviderCache.js +82 -0
  23. package/dist/hooks/provider/useBundleLoading.d.ts +2 -1
  24. package/dist/hooks/provider/useBundleLoading.js +15 -3
  25. package/dist/utils/api.d.ts +3 -78
  26. package/dist/utils/api.js +1 -53
  27. package/dist/utils/apiTypes.d.ts +78 -0
  28. package/dist/utils/apiTypes.js +1 -0
  29. package/dist/utils/apiUtils.d.ts +4 -0
  30. package/dist/utils/apiUtils.js +54 -0
  31. package/dist/utils/languageFlags.d.ts +7 -0
  32. package/dist/utils/languageFlags.js +90 -0
  33. package/dist/utils/markerEngine.d.ts +8 -66
  34. package/dist/utils/markerEngine.js +19 -703
  35. package/dist/utils/markerEngineApply.d.ts +3 -0
  36. package/dist/utils/markerEngineApply.js +136 -0
  37. package/dist/utils/markerEngineConstants.d.ts +10 -0
  38. package/dist/utils/markerEngineConstants.js +12 -0
  39. package/dist/utils/markerEngineCritical.d.ts +2 -0
  40. package/dist/utils/markerEngineCritical.js +98 -0
  41. package/dist/utils/markerEngineDomUtils.d.ts +8 -0
  42. package/dist/utils/markerEngineDomUtils.js +74 -0
  43. package/dist/utils/markerEngineFilters.d.ts +2 -0
  44. package/dist/utils/markerEngineFilters.js +26 -0
  45. package/dist/utils/markerEngineMisses.d.ts +5 -0
  46. package/dist/utils/markerEngineMisses.js +81 -0
  47. package/dist/utils/markerEngineOriginals.d.ts +5 -0
  48. package/dist/utils/markerEngineOriginals.js +29 -0
  49. package/dist/utils/markerEngineScan.d.ts +5 -0
  50. package/dist/utils/markerEngineScan.js +162 -0
  51. package/dist/utils/markerEngineState.d.ts +4 -0
  52. package/dist/utils/markerEngineState.js +14 -0
  53. package/dist/utils/markerEngineStats.d.ts +3 -0
  54. package/dist/utils/markerEngineStats.js +28 -0
  55. package/dist/utils/markerEngineTranslations.d.ts +3 -0
  56. package/dist/utils/markerEngineTranslations.js +49 -0
  57. package/dist/utils/markerEngineTypes.d.ts +62 -0
  58. package/dist/utils/markerEngineTypes.js +1 -0
  59. package/dist/utils/markerEngineViewport.d.ts +2 -0
  60. package/dist/utils/markerEngineViewport.js +27 -0
  61. package/dist/utils/mergeEntitlements.d.ts +2 -0
  62. package/dist/utils/mergeEntitlements.js +7 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/package.json +1 -1
  66. package/dist/utils/translator.d.ts +0 -80
  67. 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
- 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: {