@lovalingo/lovalingo 0.0.12 → 0.0.14
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/README.md +18 -0
- package/dist/components/AixsterProvider.js +116 -1
- package/dist/components/LangRouter.js +2 -2
- package/dist/components/LanguageSwitcher.d.ts +5 -0
- package/dist/components/LanguageSwitcher.js +65 -21
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/utils/api.d.ts +12 -0
- package/dist/utils/api.js +55 -0
- package/dist/utils/translator.d.ts +8 -0
- package/dist/utils/translator.js +99 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,24 @@ function App() {
|
|
|
46
46
|
|
|
47
47
|
**Important:** Pass the same `navigateRef` to both `<LangRouter>` and `<LovalingoProvider>` for proper URL synchronization.
|
|
48
48
|
|
|
49
|
+
**Common error (React Router v6):** If you see `LovalingoProvider is not a <Route> component`, you’re either:
|
|
50
|
+
- on an older `@lovalingo/lovalingo` version (upgrade), or
|
|
51
|
+
- you accidentally placed `<LovalingoProvider>` directly inside `<Routes>`.
|
|
52
|
+
|
|
53
|
+
Correct:
|
|
54
|
+
```tsx
|
|
55
|
+
<LovalingoProvider ...>
|
|
56
|
+
<Routes>...</Routes>
|
|
57
|
+
</LovalingoProvider>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Incorrect:
|
|
61
|
+
```tsx
|
|
62
|
+
<Routes>
|
|
63
|
+
<LovalingoProvider ... />
|
|
64
|
+
</Routes>
|
|
65
|
+
```
|
|
66
|
+
|
|
49
67
|
URLs automatically work as:
|
|
50
68
|
- `/en/`, `/en/home`, `/en/pricing`, `/en/about`
|
|
51
69
|
- `/fr/`, `/fr/home`, `/fr/pricing`, `/fr/about`
|
|
@@ -37,6 +37,7 @@ navigateRef, // For path mode routing
|
|
|
37
37
|
? { ...pathNormalization, supportedLocales: allLocales }
|
|
38
38
|
: pathNormalization;
|
|
39
39
|
const apiRef = useRef(new LovalingoAPI(apiKey, apiBase, enhancedPathConfig));
|
|
40
|
+
const [entitlements, setEntitlements] = useState(() => apiRef.current.getEntitlements());
|
|
40
41
|
const observerRef = useRef(null);
|
|
41
42
|
const missReportIntervalRef = useRef(null);
|
|
42
43
|
const retryTimeoutRef = useRef(null);
|
|
@@ -60,6 +61,88 @@ navigateRef, // For path mode routing
|
|
|
60
61
|
pathNormalization,
|
|
61
62
|
mode,
|
|
62
63
|
};
|
|
64
|
+
const setDocumentLocale = useCallback((nextLocale) => {
|
|
65
|
+
try {
|
|
66
|
+
const html = document.documentElement;
|
|
67
|
+
if (!html)
|
|
68
|
+
return;
|
|
69
|
+
html.setAttribute("lang", nextLocale);
|
|
70
|
+
const rtlLocales = new Set(["ar", "he", "fa", "ur"]);
|
|
71
|
+
html.setAttribute("dir", rtlLocales.has(nextLocale) ? "rtl" : "ltr");
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// ignore
|
|
75
|
+
}
|
|
76
|
+
}, []);
|
|
77
|
+
const updateSeoLinks = useCallback((activeLocale, hreflangEnabled) => {
|
|
78
|
+
try {
|
|
79
|
+
const head = document.head;
|
|
80
|
+
if (!head)
|
|
81
|
+
return;
|
|
82
|
+
// Remove old links inserted by Lovalingo
|
|
83
|
+
head.querySelectorAll('link[data-Lovalingo="hreflang"], link[data-Lovalingo="canonical"]').forEach((el) => el.remove());
|
|
84
|
+
const all = allLocales;
|
|
85
|
+
const url = new URL(window.location.href);
|
|
86
|
+
// Derive a locale-neutral base pathname for path routing
|
|
87
|
+
let basePathname = url.pathname;
|
|
88
|
+
if (routing === "path") {
|
|
89
|
+
const parts = basePathname.split("/").filter(Boolean);
|
|
90
|
+
if (parts.length > 0 && all.includes(parts[0])) {
|
|
91
|
+
parts.shift();
|
|
92
|
+
}
|
|
93
|
+
basePathname = "/" + parts.join("/");
|
|
94
|
+
if (basePathname === "/") {
|
|
95
|
+
// ok
|
|
96
|
+
}
|
|
97
|
+
else if (basePathname.endsWith("/") && basePathname.length > 1) {
|
|
98
|
+
basePathname = basePathname.slice(0, -1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const buildUrlForLocale = (localeForUrl) => {
|
|
102
|
+
const next = new URL(window.location.origin + basePathname + url.search + url.hash);
|
|
103
|
+
if (routing === "path") {
|
|
104
|
+
const path = basePathname === "/" ? "" : basePathname;
|
|
105
|
+
next.pathname = localeForUrl === defaultLocale ? `${path || "/"}` : `/${localeForUrl}${path}`;
|
|
106
|
+
return next.toString();
|
|
107
|
+
}
|
|
108
|
+
// Query mode: keep pathname, set/remove locale param
|
|
109
|
+
next.searchParams.delete("t");
|
|
110
|
+
next.searchParams.delete("locale");
|
|
111
|
+
if (localeForUrl !== defaultLocale) {
|
|
112
|
+
next.searchParams.set("t", localeForUrl);
|
|
113
|
+
}
|
|
114
|
+
return next.toString();
|
|
115
|
+
};
|
|
116
|
+
// Canonical should point to the current locale variant
|
|
117
|
+
const canonicalHref = buildUrlForLocale(activeLocale);
|
|
118
|
+
const canonical = document.createElement("link");
|
|
119
|
+
canonical.rel = "canonical";
|
|
120
|
+
canonical.href = canonicalHref;
|
|
121
|
+
canonical.setAttribute("data-Lovalingo", "canonical");
|
|
122
|
+
head.appendChild(canonical);
|
|
123
|
+
if (!hreflangEnabled)
|
|
124
|
+
return;
|
|
125
|
+
// hreflang alternates for each locale
|
|
126
|
+
all.forEach((loc) => {
|
|
127
|
+
const link = document.createElement("link");
|
|
128
|
+
link.rel = "alternate";
|
|
129
|
+
link.hreflang = loc;
|
|
130
|
+
link.href = buildUrlForLocale(loc);
|
|
131
|
+
link.setAttribute("data-Lovalingo", "hreflang");
|
|
132
|
+
head.appendChild(link);
|
|
133
|
+
});
|
|
134
|
+
// x-default -> default locale
|
|
135
|
+
const xDefault = document.createElement("link");
|
|
136
|
+
xDefault.rel = "alternate";
|
|
137
|
+
xDefault.hreflang = "x-default";
|
|
138
|
+
xDefault.href = buildUrlForLocale(defaultLocale);
|
|
139
|
+
xDefault.setAttribute("data-Lovalingo", "hreflang");
|
|
140
|
+
head.appendChild(xDefault);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
console.warn("[Lovalingo] updateSeoLinks() failed:", e);
|
|
144
|
+
}
|
|
145
|
+
}, [allLocales, defaultLocale, routing]);
|
|
63
146
|
// Detect locale from URL or localStorage
|
|
64
147
|
const detectLocale = useCallback(() => {
|
|
65
148
|
// 1. Check URL first based on routing mode
|
|
@@ -92,6 +175,23 @@ navigateRef, // For path mode routing
|
|
|
92
175
|
// 3. Default locale
|
|
93
176
|
return defaultLocale;
|
|
94
177
|
}, [allLocales, defaultLocale, routing]);
|
|
178
|
+
// Fetch entitlements early so SEO can be enabled even on default locale
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
let cancelled = false;
|
|
181
|
+
(async () => {
|
|
182
|
+
const next = await apiRef.current.fetchEntitlements(detectLocale());
|
|
183
|
+
if (!cancelled && next)
|
|
184
|
+
setEntitlements(next);
|
|
185
|
+
})();
|
|
186
|
+
return () => {
|
|
187
|
+
cancelled = true;
|
|
188
|
+
};
|
|
189
|
+
}, [detectLocale]);
|
|
190
|
+
// Keep <html lang> + canonical/hreflang in sync with routing + entitlements
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
setDocumentLocale(locale);
|
|
193
|
+
updateSeoLinks(locale, Boolean(entitlements?.hreflangEnabled));
|
|
194
|
+
}, [locale, entitlements, setDocumentLocale, updateSeoLinks]);
|
|
95
195
|
// Load translations and exclusions
|
|
96
196
|
const loadData = useCallback(async (targetLocale, previousLocale, showOverlay = false) => {
|
|
97
197
|
// Cancel any pending retry scan to prevent race conditions
|
|
@@ -106,6 +206,7 @@ navigateRef, // For path mode routing
|
|
|
106
206
|
setIsNavigationLoading(false);
|
|
107
207
|
translatorRef.current.setTranslations([]);
|
|
108
208
|
translatorRef.current.restoreDOM(); // Safe to restore when going back to source language
|
|
209
|
+
translatorRef.current.restoreHead();
|
|
109
210
|
isNavigatingRef.current = false;
|
|
110
211
|
return;
|
|
111
212
|
}
|
|
@@ -121,6 +222,7 @@ navigateRef, // For path mode routing
|
|
|
121
222
|
translatorRef.current.setTranslations(cachedTranslations);
|
|
122
223
|
translatorRef.current.setExclusions(cachedExclusions);
|
|
123
224
|
translatorRef.current.translateDOM();
|
|
225
|
+
translatorRef.current.translateHead();
|
|
124
226
|
// Delayed retry scan to catch late-rendering content
|
|
125
227
|
retryTimeoutRef.current = setTimeout(() => {
|
|
126
228
|
// Don't scan if we're navigating (prevents React conflicts)
|
|
@@ -129,6 +231,7 @@ navigateRef, // For path mode routing
|
|
|
129
231
|
}
|
|
130
232
|
console.log(`[Lovalingo] 🔄 Retry scan for late-rendering content`);
|
|
131
233
|
translatorRef.current.translateDOM();
|
|
234
|
+
translatorRef.current.translateHead();
|
|
132
235
|
// Immediately report any misses found
|
|
133
236
|
const missed = translatorRef.current.getMissedStrings();
|
|
134
237
|
if (missed.length > 0) {
|
|
@@ -157,12 +260,16 @@ navigateRef, // For path mode routing
|
|
|
157
260
|
apiRef.current.fetchTranslations(defaultLocale, targetLocale),
|
|
158
261
|
apiRef.current.fetchExclusions(),
|
|
159
262
|
]);
|
|
263
|
+
const nextEntitlements = apiRef.current.getEntitlements();
|
|
264
|
+
if (nextEntitlements)
|
|
265
|
+
setEntitlements(nextEntitlements);
|
|
160
266
|
// Store in cache for next time
|
|
161
267
|
translationCacheRef.current.set(cacheKey, translations);
|
|
162
268
|
exclusionsCacheRef.current = exclusions;
|
|
163
269
|
translatorRef.current.setTranslations(translations);
|
|
164
270
|
translatorRef.current.setExclusions(exclusions);
|
|
165
271
|
translatorRef.current.translateDOM();
|
|
272
|
+
translatorRef.current.translateHead();
|
|
166
273
|
// Delayed retry scan to catch late-rendering content
|
|
167
274
|
retryTimeoutRef.current = setTimeout(() => {
|
|
168
275
|
// Don't scan if we're navigating (prevents React conflicts)
|
|
@@ -171,6 +278,7 @@ navigateRef, // For path mode routing
|
|
|
171
278
|
}
|
|
172
279
|
console.log(`[Lovalingo] 🔄 Retry scan for late-rendering content`);
|
|
173
280
|
translatorRef.current.translateDOM();
|
|
281
|
+
translatorRef.current.translateHead();
|
|
174
282
|
// Immediately report any misses found
|
|
175
283
|
const missed = translatorRef.current.getMissedStrings();
|
|
176
284
|
if (missed.length > 0) {
|
|
@@ -281,6 +389,11 @@ navigateRef, // For path mode routing
|
|
|
281
389
|
console.log(`[Lovalingo] v4.0.0 initialized (mode: ${mode})`);
|
|
282
390
|
const initialLocale = detectLocale();
|
|
283
391
|
setLocaleState(initialLocale);
|
|
392
|
+
// Fetch tier/entitlements early (so the badge can render even on default locale)
|
|
393
|
+
apiRef.current.fetchEntitlements(initialLocale).then((next) => {
|
|
394
|
+
if (next)
|
|
395
|
+
setEntitlements(next);
|
|
396
|
+
});
|
|
284
397
|
// Only load data for DOM mode (context mode uses AutoTranslate)
|
|
285
398
|
if (mode === 'dom') {
|
|
286
399
|
loadData(initialLocale);
|
|
@@ -490,6 +603,8 @@ navigateRef, // For path mode routing
|
|
|
490
603
|
};
|
|
491
604
|
return (React.createElement(LovalingoContext.Provider, { value: contextValue },
|
|
492
605
|
children,
|
|
493
|
-
React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY
|
|
606
|
+
React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, branding: entitlements?.brandingRequired
|
|
607
|
+
? { required: true, href: "https://lovalingo.com" }
|
|
608
|
+
: undefined }),
|
|
494
609
|
React.createElement(NavigationOverlay, { isVisible: isNavigationLoading })));
|
|
495
610
|
};
|
|
@@ -54,7 +54,7 @@ export function LangRouter({ children, defaultLang, langs, navigateRef }) {
|
|
|
54
54
|
React.createElement(NavigateExporter, { navigateRef: navigateRef }),
|
|
55
55
|
React.createElement(Routes, null,
|
|
56
56
|
React.createElement(Route, { path: ":lang/*", element: React.createElement(LangGuard, { defaultLang: defaultLang, langs: langs }) },
|
|
57
|
-
React.createElement(Route, { index: true, element: null }),
|
|
58
|
-
children),
|
|
57
|
+
React.createElement(Route, { index: true, element: React.createElement(React.Fragment, null, children) }),
|
|
58
|
+
React.createElement(Route, { path: "*", element: React.createElement(React.Fragment, null, children) })),
|
|
59
59
|
React.createElement(Route, { path: "*", element: React.createElement(Navigate, { to: `/${defaultLang}`, replace: true }) }))));
|
|
60
60
|
}
|
|
@@ -5,6 +5,11 @@ interface LanguageSwitcherProps {
|
|
|
5
5
|
onLocaleChange: (locale: string) => void;
|
|
6
6
|
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
7
7
|
offsetY?: number;
|
|
8
|
+
branding?: {
|
|
9
|
+
required?: boolean;
|
|
10
|
+
label?: string;
|
|
11
|
+
href?: string;
|
|
12
|
+
};
|
|
8
13
|
}
|
|
9
14
|
export declare const LanguageSwitcher: React.FC<LanguageSwitcherProps>;
|
|
10
15
|
export {};
|
|
@@ -22,7 +22,7 @@ const LANGUAGE_FLAGS = {
|
|
|
22
22
|
no: '🇳🇴',
|
|
23
23
|
fi: '🇫🇮',
|
|
24
24
|
};
|
|
25
|
-
export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, position = 'bottom-right', offsetY = 20, }) => {
|
|
25
|
+
export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, position = 'bottom-right', offsetY = 20, branding, }) => {
|
|
26
26
|
const [isOpen, setIsOpen] = useState(false);
|
|
27
27
|
const containerRef = useRef(null);
|
|
28
28
|
const isRight = position.endsWith('right');
|
|
@@ -104,13 +104,37 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
|
|
|
104
104
|
pointerEvents: isOpen ? 'auto' : 'none',
|
|
105
105
|
background: 'rgba(26, 26, 26, 0.93)',
|
|
106
106
|
backdropFilter: 'blur(12px)',
|
|
107
|
-
borderRadius: '
|
|
108
|
-
padding: '
|
|
107
|
+
borderRadius: '16px',
|
|
108
|
+
padding: '10px 12px',
|
|
109
109
|
display: 'flex',
|
|
110
|
+
flexDirection: 'column',
|
|
110
111
|
gap: '10px',
|
|
111
112
|
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.3), inset 0 0 1px rgba(255, 255, 255, 0.1)',
|
|
112
113
|
transition: 'opacity 0.25s ease, transform 0.25s ease',
|
|
113
114
|
};
|
|
115
|
+
const localeRowStyles = {
|
|
116
|
+
display: 'flex',
|
|
117
|
+
gap: '10px',
|
|
118
|
+
padding: '0 2px',
|
|
119
|
+
};
|
|
120
|
+
const badgeRowStyles = {
|
|
121
|
+
display: 'flex',
|
|
122
|
+
alignItems: 'center',
|
|
123
|
+
gap: '8px',
|
|
124
|
+
paddingTop: '8px',
|
|
125
|
+
borderTop: '1px solid rgba(255, 255, 255, 0.12)',
|
|
126
|
+
fontSize: '12px',
|
|
127
|
+
color: 'rgba(255, 255, 255, 0.82)',
|
|
128
|
+
userSelect: 'none',
|
|
129
|
+
whiteSpace: 'nowrap',
|
|
130
|
+
};
|
|
131
|
+
const badgeLinkStyles = {
|
|
132
|
+
color: 'rgba(255, 255, 255, 0.92)',
|
|
133
|
+
textDecoration: 'none',
|
|
134
|
+
display: 'inline-flex',
|
|
135
|
+
alignItems: 'center',
|
|
136
|
+
gap: '6px',
|
|
137
|
+
};
|
|
114
138
|
const flagButtonStyles = (locale) => ({
|
|
115
139
|
pointerEvents: 'auto',
|
|
116
140
|
width: '32px',
|
|
@@ -141,22 +165,42 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
|
|
|
141
165
|
? '-2px 0 8px rgba(0, 0, 0, 0.2), inset 0 0 1px rgba(255, 255, 255, 0.1)'
|
|
142
166
|
: '2px 0 8px rgba(0, 0, 0, 0.2), inset 0 0 1px rgba(255, 255, 255, 0.1)';
|
|
143
167
|
}, "aria-label": "Open language switcher", "aria-expanded": isOpen }, LANGUAGE_FLAGS[currentLocale] || '🌐'),
|
|
144
|
-
React.createElement("div", { style: panelStyles, role: "toolbar", "aria-label": "Language options" },
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
168
|
+
React.createElement("div", { style: panelStyles, role: "toolbar", "aria-label": "Language options" },
|
|
169
|
+
React.createElement("div", { style: localeRowStyles }, orderedLocales.map((locale) => (React.createElement("button", { key: locale, style: flagButtonStyles(locale), onClick: (e) => {
|
|
170
|
+
e.stopPropagation();
|
|
171
|
+
if (locale === currentLocale) {
|
|
172
|
+
setIsOpen(false);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
onLocaleChange(locale);
|
|
176
|
+
setIsOpen(false);
|
|
177
|
+
}
|
|
178
|
+
}, onMouseEnter: (e) => {
|
|
179
|
+
if (locale !== currentLocale) {
|
|
180
|
+
e.currentTarget.style.filter = 'brightness(1.3)';
|
|
181
|
+
}
|
|
182
|
+
e.currentTarget.style.transform = 'scale(1.1)';
|
|
183
|
+
}, onMouseLeave: (e) => {
|
|
184
|
+
e.currentTarget.style.filter = 'brightness(1)';
|
|
185
|
+
e.currentTarget.style.transform = 'scale(1)';
|
|
186
|
+
}, "aria-label": `Switch to ${locale.toUpperCase()}`, title: locale.toUpperCase(), tabIndex: isOpen ? 0 : -1 }, LANGUAGE_FLAGS[locale] || '🏳️')))),
|
|
187
|
+
branding?.required && (React.createElement("div", { style: badgeRowStyles, "aria-label": "Lovalingo branding" },
|
|
188
|
+
React.createElement("a", { href: branding.href || 'https://lovalingo.com', target: "_blank", rel: "noreferrer", style: badgeLinkStyles, tabIndex: isOpen ? 0 : -1, "aria-label": "Localized by Lovalingo", title: "Localized by Lovalingo" },
|
|
189
|
+
React.createElement("span", { style: {
|
|
190
|
+
width: '16px',
|
|
191
|
+
height: '16px',
|
|
192
|
+
borderRadius: '5px',
|
|
193
|
+
background: '#6BD63D',
|
|
194
|
+
display: 'inline-flex',
|
|
195
|
+
alignItems: 'center',
|
|
196
|
+
justifyContent: 'center',
|
|
197
|
+
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.25)',
|
|
198
|
+
flexShrink: 0,
|
|
199
|
+
} },
|
|
200
|
+
React.createElement("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true" },
|
|
201
|
+
React.createElement("path", { d: "M9 5a2 2 0 0 1 2 2v10h8a2 2 0 1 1 0 4H9a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z", fill: "#0D0D0D" }))),
|
|
202
|
+
React.createElement("span", null,
|
|
203
|
+
branding.label || 'Localized by',
|
|
204
|
+
" ",
|
|
205
|
+
React.createElement("strong", { style: { color: 'white' } }, "Lovalingo")))))))));
|
|
162
206
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -22,4 +22,5 @@ export { useLangNavigate } from './hooks/useLangNavigate';
|
|
|
22
22
|
export { useLovalingo } from './hooks/useLovalingo';
|
|
23
23
|
export { useLovalingoTranslate } from './hooks/useLovalingoTranslate';
|
|
24
24
|
export { useLovalingoEdit } from './hooks/useLovalingoEdit';
|
|
25
|
+
export { VERSION } from './version';
|
|
25
26
|
export type { LovalingoConfig, LovalingoContextValue, Translation, Exclusion, HashTranslation } from './types';
|
package/dist/index.js
CHANGED
|
@@ -25,3 +25,5 @@ export { useLangNavigate } from './hooks/useLangNavigate';
|
|
|
25
25
|
export { useLovalingo } from './hooks/useLovalingo';
|
|
26
26
|
export { useLovalingoTranslate } from './hooks/useLovalingoTranslate';
|
|
27
27
|
export { useLovalingoEdit } from './hooks/useLovalingoEdit';
|
|
28
|
+
// Version
|
|
29
|
+
export { VERSION } from './version';
|
package/dist/utils/api.d.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { Translation, Exclusion, MissedTranslation } from '../types';
|
|
2
2
|
import { PathNormalizationConfig } from './pathNormalizer';
|
|
3
|
+
export interface ProjectEntitlements {
|
|
4
|
+
tier: 'starter' | 'startup' | 'global';
|
|
5
|
+
maxTargetLocales: number;
|
|
6
|
+
allowedTargetLocales: string[];
|
|
7
|
+
brandingRequired: boolean;
|
|
8
|
+
hreflangEnabled: boolean;
|
|
9
|
+
}
|
|
3
10
|
export declare class LovalingoAPI {
|
|
4
11
|
private apiKey;
|
|
5
12
|
private apiBase;
|
|
6
13
|
private pathConfig?;
|
|
14
|
+
private entitlements;
|
|
7
15
|
constructor(apiKey: string, apiBase: string, pathConfig?: PathNormalizationConfig);
|
|
16
|
+
private hasApiKey;
|
|
17
|
+
private warnMissingApiKey;
|
|
18
|
+
getEntitlements(): ProjectEntitlements | null;
|
|
19
|
+
fetchEntitlements(localeHint: string): Promise<ProjectEntitlements | null>;
|
|
8
20
|
fetchTranslations(sourceLocale: string, targetLocale: string): Promise<Translation[]>;
|
|
9
21
|
fetchExclusions(): Promise<Exclusion[]>;
|
|
10
22
|
reportMisses(misses: MissedTranslation[], sourceLocale: string, targetLocale: string): Promise<void>;
|
package/dist/utils/api.js
CHANGED
|
@@ -1,18 +1,57 @@
|
|
|
1
1
|
import { processPath } from './pathNormalizer';
|
|
2
2
|
export class LovalingoAPI {
|
|
3
3
|
constructor(apiKey, apiBase, pathConfig) {
|
|
4
|
+
this.entitlements = null;
|
|
4
5
|
this.apiKey = apiKey;
|
|
5
6
|
this.apiBase = apiBase;
|
|
6
7
|
this.pathConfig = pathConfig;
|
|
7
8
|
}
|
|
9
|
+
hasApiKey() {
|
|
10
|
+
return typeof this.apiKey === 'string' && this.apiKey.trim().length > 0;
|
|
11
|
+
}
|
|
12
|
+
warnMissingApiKey(action) {
|
|
13
|
+
// Avoid hard-crashing apps; make the failure mode obvious.
|
|
14
|
+
console.warn(`[Lovalingo] Missing apiKey: ${action} was skipped. Pass apiKey to <LovalingoProvider apiKey="..."> (or set VITE_LOVALINGO_API_KEY).`);
|
|
15
|
+
}
|
|
16
|
+
getEntitlements() {
|
|
17
|
+
return this.entitlements;
|
|
18
|
+
}
|
|
19
|
+
async fetchEntitlements(localeHint) {
|
|
20
|
+
try {
|
|
21
|
+
if (!this.hasApiKey()) {
|
|
22
|
+
this.warnMissingApiKey('fetchEntitlements');
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const normalizedPath = processPath(window.location.pathname, this.pathConfig);
|
|
26
|
+
const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${localeHint}&path=${normalizedPath}`);
|
|
27
|
+
if (!response.ok)
|
|
28
|
+
return null;
|
|
29
|
+
const data = await response.json();
|
|
30
|
+
if (data?.entitlements) {
|
|
31
|
+
this.entitlements = data.entitlements;
|
|
32
|
+
return this.entitlements;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
8
40
|
async fetchTranslations(sourceLocale, targetLocale) {
|
|
9
41
|
try {
|
|
42
|
+
if (!this.hasApiKey()) {
|
|
43
|
+
this.warnMissingApiKey('fetchTranslations');
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
10
46
|
// Use path normalization utility
|
|
11
47
|
const normalizedPath = processPath(window.location.pathname, this.pathConfig);
|
|
12
48
|
const response = await fetch(`${this.apiBase}/functions/v1/bundle?key=${this.apiKey}&locale=${targetLocale}&path=${normalizedPath}`);
|
|
13
49
|
if (!response.ok)
|
|
14
50
|
throw new Error('Failed to fetch translations');
|
|
15
51
|
const data = await response.json();
|
|
52
|
+
if (data?.entitlements) {
|
|
53
|
+
this.entitlements = data.entitlements;
|
|
54
|
+
}
|
|
16
55
|
// Convert map to array of Translation objects
|
|
17
56
|
if (data.map && typeof data.map === 'object') {
|
|
18
57
|
return Object.entries(data.map).map(([source_text, translated_text]) => ({
|
|
@@ -31,6 +70,10 @@ export class LovalingoAPI {
|
|
|
31
70
|
}
|
|
32
71
|
async fetchExclusions() {
|
|
33
72
|
try {
|
|
73
|
+
if (!this.hasApiKey()) {
|
|
74
|
+
this.warnMissingApiKey('fetchExclusions');
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
34
77
|
const response = await fetch(`${this.apiBase}/functions/v1/exclusions?key=${this.apiKey}`);
|
|
35
78
|
if (!response.ok)
|
|
36
79
|
throw new Error('Failed to fetch exclusions');
|
|
@@ -45,6 +88,10 @@ export class LovalingoAPI {
|
|
|
45
88
|
}
|
|
46
89
|
async reportMisses(misses, sourceLocale, targetLocale) {
|
|
47
90
|
try {
|
|
91
|
+
if (!this.hasApiKey()) {
|
|
92
|
+
this.warnMissingApiKey('reportMisses');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
48
95
|
// Use path normalization utility
|
|
49
96
|
const normalizedPath = processPath(window.location.pathname, this.pathConfig);
|
|
50
97
|
// CRITICAL: Filter out invalid misses
|
|
@@ -94,6 +141,10 @@ export class LovalingoAPI {
|
|
|
94
141
|
}
|
|
95
142
|
async saveExclusion(selector, type) {
|
|
96
143
|
try {
|
|
144
|
+
if (!this.hasApiKey()) {
|
|
145
|
+
this.warnMissingApiKey('saveExclusion');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
97
148
|
await fetch(`${this.apiBase}/functions/v1/exclusions?key=${this.apiKey}`, {
|
|
98
149
|
method: 'POST',
|
|
99
150
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -111,6 +162,10 @@ export class LovalingoAPI {
|
|
|
111
162
|
*/
|
|
112
163
|
async translateRealtime(contentHash, sourceText, sourceLocale, targetLocale) {
|
|
113
164
|
try {
|
|
165
|
+
if (!this.hasApiKey()) {
|
|
166
|
+
this.warnMissingApiKey('translateRealtime');
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
114
169
|
console.log(`[Lovalingo] 🚀 Real-time translation: "${sourceText.substring(0, 40)}..."`);
|
|
115
170
|
const response = await fetch(`${this.apiBase}/functions/v1/translate-realtime`, {
|
|
116
171
|
method: 'POST',
|
|
@@ -77,4 +77,12 @@ export declare class Translator {
|
|
|
77
77
|
*/
|
|
78
78
|
private translateAttribute;
|
|
79
79
|
translateDOM(): void;
|
|
80
|
+
/**
|
|
81
|
+
* Translate SEO-relevant <head> elements (title + meta content) that are not part of the body DOM tree.
|
|
82
|
+
*/
|
|
83
|
+
translateHead(): void;
|
|
84
|
+
/**
|
|
85
|
+
* Restore original <head> SEO content (title + meta content) after returning to default locale.
|
|
86
|
+
*/
|
|
87
|
+
restoreHead(): void;
|
|
80
88
|
}
|
package/dist/utils/translator.js
CHANGED
|
@@ -763,4 +763,103 @@ export class Translator {
|
|
|
763
763
|
const elapsed = performance.now() - startTime;
|
|
764
764
|
console.log(`[Lovalingo] 🏁 translateDOM() complete in ${elapsed.toFixed(2)}ms. Missed: ${this.missedStrings.size}`);
|
|
765
765
|
}
|
|
766
|
+
/**
|
|
767
|
+
* Translate SEO-relevant <head> elements (title + meta content) that are not part of the body DOM tree.
|
|
768
|
+
*/
|
|
769
|
+
translateHead() {
|
|
770
|
+
try {
|
|
771
|
+
const head = document.head;
|
|
772
|
+
if (!head)
|
|
773
|
+
return;
|
|
774
|
+
const titleEl = head.querySelector("title");
|
|
775
|
+
if (titleEl) {
|
|
776
|
+
const originalKey = "data-Lovalingo-title-original";
|
|
777
|
+
if (!titleEl.getAttribute(originalKey)) {
|
|
778
|
+
titleEl.setAttribute(originalKey, titleEl.textContent || "");
|
|
779
|
+
}
|
|
780
|
+
const sourceTitle = (titleEl.getAttribute(originalKey) || "").trim();
|
|
781
|
+
if (sourceTitle && this.isTranslatableText(sourceTitle)) {
|
|
782
|
+
const translated = this.translationMap.get(sourceTitle);
|
|
783
|
+
if (translated) {
|
|
784
|
+
titleEl.textContent = translated;
|
|
785
|
+
}
|
|
786
|
+
else if (sourceTitle.length < 500) {
|
|
787
|
+
this.missedStrings.set(sourceTitle, {
|
|
788
|
+
text: sourceTitle,
|
|
789
|
+
raw: sourceTitle,
|
|
790
|
+
placeholderMap: {},
|
|
791
|
+
semanticContext: "title",
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const metaSelectors = [
|
|
797
|
+
'meta[name="description"]',
|
|
798
|
+
'meta[property="og:title"]',
|
|
799
|
+
'meta[property="og:description"]',
|
|
800
|
+
'meta[name="twitter:title"]',
|
|
801
|
+
'meta[name="twitter:description"]',
|
|
802
|
+
];
|
|
803
|
+
metaSelectors.forEach((selector) => {
|
|
804
|
+
head.querySelectorAll(selector).forEach((node) => {
|
|
805
|
+
if (!(node instanceof HTMLMetaElement))
|
|
806
|
+
return;
|
|
807
|
+
const originalAttrKey = "data-Lovalingo-content-original";
|
|
808
|
+
if (!node.getAttribute(originalAttrKey)) {
|
|
809
|
+
const content = (node.getAttribute("content") || "").trim();
|
|
810
|
+
node.setAttribute(originalAttrKey, content);
|
|
811
|
+
}
|
|
812
|
+
const sourceValue = (node.getAttribute(originalAttrKey) || "").trim();
|
|
813
|
+
if (!sourceValue || sourceValue.length <= 1)
|
|
814
|
+
return;
|
|
815
|
+
if (!this.isTranslatableText(sourceValue))
|
|
816
|
+
return;
|
|
817
|
+
const translated = this.translationMap.get(sourceValue);
|
|
818
|
+
if (translated) {
|
|
819
|
+
node.setAttribute("content", translated);
|
|
820
|
+
}
|
|
821
|
+
else if (sourceValue.length < 500) {
|
|
822
|
+
const context = selector.includes("description") ? "meta-description" : "meta-title";
|
|
823
|
+
this.missedStrings.set(sourceValue, {
|
|
824
|
+
text: sourceValue,
|
|
825
|
+
raw: sourceValue,
|
|
826
|
+
placeholderMap: {},
|
|
827
|
+
semanticContext: context,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
catch (e) {
|
|
834
|
+
console.warn("[Lovalingo] translateHead() failed:", e);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Restore original <head> SEO content (title + meta content) after returning to default locale.
|
|
839
|
+
*/
|
|
840
|
+
restoreHead() {
|
|
841
|
+
try {
|
|
842
|
+
const head = document.head;
|
|
843
|
+
if (!head)
|
|
844
|
+
return;
|
|
845
|
+
const titleEl = head.querySelector("title");
|
|
846
|
+
if (titleEl) {
|
|
847
|
+
const original = titleEl.getAttribute("data-Lovalingo-title-original");
|
|
848
|
+
if (original !== null) {
|
|
849
|
+
titleEl.textContent = original;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
head.querySelectorAll('meta[data-Lovalingo-content-original]').forEach((node) => {
|
|
853
|
+
if (!(node instanceof HTMLMetaElement))
|
|
854
|
+
return;
|
|
855
|
+
const original = node.getAttribute("data-Lovalingo-content-original");
|
|
856
|
+
if (original !== null) {
|
|
857
|
+
node.setAttribute("content", original);
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
catch (e) {
|
|
862
|
+
console.warn("[Lovalingo] restoreHead() failed:", e);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
766
865
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION = "0.0.14";
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.0.14";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovalingo/lovalingo",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "React translation library with automatic routing, real-time AI translation, and zero-flash rendering. One-line language routing setup.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|