@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.
- 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 +5 -2
- 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/seoUtils.d.ts +3 -0
- package/dist/components/provider/seoUtils.js +10 -5
- package/dist/hooks/provider/useBundleLoading.d.ts +2 -1
- package/dist/hooks/provider/useBundleLoading.js +15 -3
- package/dist/utils/languageFlags.d.ts +7 -0
- package/dist/utils/languageFlags.js +90 -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
|
@@ -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: {
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.5.
|
|
1
|
+
export declare const VERSION = "0.5.28";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.5.
|
|
1
|
+
export const VERSION = "0.5.28";
|
package/package.json
CHANGED