@lovalingo/lovalingo 0.0.11 → 0.0.13
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 +38 -20
- 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/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/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @lovalingo/lovalingo
|
|
2
2
|
|
|
3
3
|
Seamless website translation for React and Next.js applications with **zero-flash rendering** and **automatic language routing**.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install @
|
|
8
|
+
npm install @lovalingo/lovalingo react-router-dom
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Quick Start
|
|
@@ -15,7 +15,7 @@ npm install @aixyte/Lovalingo react-router-dom
|
|
|
15
15
|
**One-line setup** that automatically handles language routing like `/en/pricing`, `/fr/pricing`, `/de/pricing`:
|
|
16
16
|
|
|
17
17
|
```tsx
|
|
18
|
-
import { LangRouter, LovalingoProvider } from '@
|
|
18
|
+
import { LangRouter, LovalingoProvider } from '@lovalingo/lovalingo';
|
|
19
19
|
import { Routes, Route } from 'react-router-dom';
|
|
20
20
|
import { useRef } from 'react';
|
|
21
21
|
|
|
@@ -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`
|
|
@@ -56,7 +74,7 @@ URLs automatically work as:
|
|
|
56
74
|
Use query parameters like `/pricing?t=fr`:
|
|
57
75
|
|
|
58
76
|
```tsx
|
|
59
|
-
import { LovalingoProvider } from '@
|
|
77
|
+
import { LovalingoProvider } from '@lovalingo/lovalingo';
|
|
60
78
|
import { BrowserRouter } from 'react-router-dom';
|
|
61
79
|
|
|
62
80
|
function App() {
|
|
@@ -79,7 +97,7 @@ function App() {
|
|
|
79
97
|
|
|
80
98
|
```tsx
|
|
81
99
|
// app/layout.tsx
|
|
82
|
-
import { LovalingoProvider } from '@
|
|
100
|
+
import { LovalingoProvider } from '@lovalingo/lovalingo';
|
|
83
101
|
|
|
84
102
|
export default function RootLayout({ children }) {
|
|
85
103
|
return (
|
|
@@ -112,7 +130,7 @@ export default function RootLayout({ children }) {
|
|
|
112
130
|
editMode={false} // Optional: Enable edit mode
|
|
113
131
|
editKey="KeyE" // Optional: Keyboard shortcut for edit mode
|
|
114
132
|
mode="dom" // Optional: 'dom' | 'context' (default: 'dom')
|
|
115
|
-
sitemap={true} // Optional: Auto-inject sitemap link (default: true) - NEW in
|
|
133
|
+
sitemap={true} // Optional: Auto-inject sitemap link (default: true) - NEW in v0.0.x
|
|
116
134
|
>
|
|
117
135
|
{children}
|
|
118
136
|
</LovalingoProvider>
|
|
@@ -130,12 +148,12 @@ export default function RootLayout({ children }) {
|
|
|
130
148
|
- SEO-optimized URLs
|
|
131
149
|
- See [PATH_EXAMPLES.md](./PATH_EXAMPLES.md) for detailed examples
|
|
132
150
|
|
|
133
|
-
## Path Mode Helpers (
|
|
151
|
+
## Path Mode Helpers (v0.0.x+)
|
|
134
152
|
|
|
135
153
|
### LangLink - Language-Aware Links
|
|
136
154
|
|
|
137
155
|
```tsx
|
|
138
|
-
import { LangLink } from '@
|
|
156
|
+
import { LangLink } from '@lovalingo/lovalingo';
|
|
139
157
|
|
|
140
158
|
function Navigation() {
|
|
141
159
|
return (
|
|
@@ -151,7 +169,7 @@ function Navigation() {
|
|
|
151
169
|
### useLang - Get Current Language
|
|
152
170
|
|
|
153
171
|
```tsx
|
|
154
|
-
import { useLang } from '@
|
|
172
|
+
import { useLang } from '@lovalingo/lovalingo';
|
|
155
173
|
|
|
156
174
|
function MyComponent() {
|
|
157
175
|
const lang = useLang(); // 'en', 'fr', 'de', etc.
|
|
@@ -162,7 +180,7 @@ function MyComponent() {
|
|
|
162
180
|
### useLangNavigate - Programmatic Navigation
|
|
163
181
|
|
|
164
182
|
```tsx
|
|
165
|
-
import { useLangNavigate } from '@
|
|
183
|
+
import { useLangNavigate } from '@lovalingo/lovalingo';
|
|
166
184
|
|
|
167
185
|
function MyComponent() {
|
|
168
186
|
const navigate = useLangNavigate();
|
|
@@ -182,7 +200,7 @@ function MyComponent() {
|
|
|
182
200
|
Access locale state and switching:
|
|
183
201
|
|
|
184
202
|
```tsx
|
|
185
|
-
import { useLovalingo } from '@
|
|
203
|
+
import { useLovalingo } from '@lovalingo/lovalingo';
|
|
186
204
|
|
|
187
205
|
function MyComponent() {
|
|
188
206
|
const { locale, setLocale, isLoading, config } = useLovalingo();
|
|
@@ -200,7 +218,7 @@ function MyComponent() {
|
|
|
200
218
|
Manual translation control:
|
|
201
219
|
|
|
202
220
|
```tsx
|
|
203
|
-
import { useLovalingoTranslate } from '@
|
|
221
|
+
import { useLovalingoTranslate } from '@lovalingo/lovalingo';
|
|
204
222
|
|
|
205
223
|
function MyComponent() {
|
|
206
224
|
const { translateElement, translateDOM } = useLovalingoTranslate();
|
|
@@ -223,7 +241,7 @@ function MyComponent() {
|
|
|
223
241
|
Edit mode for excluding elements:
|
|
224
242
|
|
|
225
243
|
```tsx
|
|
226
|
-
import { useLovalingoEdit } from '@
|
|
244
|
+
import { useLovalingoEdit } from '@lovalingo/lovalingo';
|
|
227
245
|
|
|
228
246
|
function MyComponent() {
|
|
229
247
|
const { editMode, toggleEditMode, excludeElement } = useLovalingoEdit();
|
|
@@ -246,11 +264,11 @@ function MyComponent() {
|
|
|
246
264
|
- ✅ Works with React Router and Next.js
|
|
247
265
|
- ✅ Edit mode for managing exclusions
|
|
248
266
|
- ✅ Automatic translation miss detection
|
|
249
|
-
- ✅ **Automatic multilingual sitemap generation (
|
|
267
|
+
- ✅ **Automatic multilingual sitemap generation (v0.0.x+)**
|
|
250
268
|
|
|
251
|
-
## Automatic Sitemap (
|
|
269
|
+
## Automatic Sitemap (v0.0.x+)
|
|
252
270
|
|
|
253
|
-
Your multilingual sitemap is **automatically generated** with zero configuration. Just install `@
|
|
271
|
+
Your multilingual sitemap is **automatically generated** with zero configuration. Just install `@lovalingo/lovalingo` and it works!
|
|
254
272
|
|
|
255
273
|
### Zero Configuration
|
|
256
274
|
|
|
@@ -264,7 +282,7 @@ Your multilingual sitemap is **automatically generated** with zero configuration
|
|
|
264
282
|
</LovalingoProvider>
|
|
265
283
|
|
|
266
284
|
// ✅ That's it! Sitemap exists automatically at:
|
|
267
|
-
// https://
|
|
285
|
+
// https://leuskvkajliuzalrlwhw.supabase.co/functions/v1/generate-sitemap/aix_xxx
|
|
268
286
|
```
|
|
269
287
|
|
|
270
288
|
### Features
|
|
@@ -304,7 +322,7 @@ Your multilingual sitemap is **automatically generated** with zero configuration
|
|
|
304
322
|
# netlify.toml
|
|
305
323
|
[[redirects]]
|
|
306
324
|
from = "/sitemap.xml"
|
|
307
|
-
to = "https://
|
|
325
|
+
to = "https://leuskvkajliuzalrlwhw.supabase.co/functions/v1/generate-sitemap/YOUR_API_KEY"
|
|
308
326
|
status = 200
|
|
309
327
|
```
|
|
310
328
|
|
|
@@ -314,7 +332,7 @@ Your multilingual sitemap is **automatically generated** with zero configuration
|
|
|
314
332
|
{
|
|
315
333
|
"rewrites": [{
|
|
316
334
|
"source": "/sitemap.xml",
|
|
317
|
-
"destination": "https://
|
|
335
|
+
"destination": "https://leuskvkajliuzalrlwhw.supabase.co/functions/v1/generate-sitemap/YOUR_API_KEY"
|
|
318
336
|
}]
|
|
319
337
|
}
|
|
320
338
|
```
|
|
@@ -325,7 +343,7 @@ Your multilingual sitemap is **automatically generated** with zero configuration
|
|
|
325
343
|
// app/sitemap.xml/route.ts
|
|
326
344
|
export async function GET() {
|
|
327
345
|
const res = await fetch(
|
|
328
|
-
'https://
|
|
346
|
+
'https://leuskvkajliuzalrlwhw.supabase.co/functions/v1/generate-sitemap/YOUR_API_KEY'
|
|
329
347
|
);
|
|
330
348
|
return new Response(await res.text(), {
|
|
331
349
|
headers: { 'Content-Type': 'application/xml' }
|
|
@@ -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/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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovalingo/lovalingo",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
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",
|