@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 CHANGED
@@ -1,11 +1,11 @@
1
- # @aixyte/Lovalingo
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 @aixyte/Lovalingo react-router-dom
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 '@aixyte/Lovalingo';
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 '@aixyte/Lovalingo';
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 '@aixyte/Lovalingo';
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 v4.2.2
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 (v4.2+)
151
+ ## Path Mode Helpers (v0.0.x+)
134
152
 
135
153
  ### LangLink - Language-Aware Links
136
154
 
137
155
  ```tsx
138
- import { LangLink } from '@aixyte/Lovalingo';
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 '@aixyte/Lovalingo';
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 '@aixyte/Lovalingo';
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 '@aixyte/Lovalingo';
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 '@aixyte/Lovalingo';
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 '@aixyte/Lovalingo';
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 (v4.2.2+)**
267
+ - ✅ **Automatic multilingual sitemap generation (v0.0.x+)**
250
268
 
251
- ## Automatic Sitemap (v4.2.2+)
269
+ ## Automatic Sitemap (v0.0.x+)
252
270
 
253
- Your multilingual sitemap is **automatically generated** with zero configuration. Just install `@aixyte/Lovalingo` and it works!
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://mvbumiwzjytmxswfjgzn.supabase.co/functions/v1/generate-sitemap/aix_xxx
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://mvbumiwzjytmxswfjgzn.supabase.co/functions/v1/generate-sitemap/YOUR_API_KEY"
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://mvbumiwzjytmxswfjgzn.supabase.co/functions/v1/generate-sitemap/YOUR_API_KEY"
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://mvbumiwzjytmxswfjgzn.supabase.co/functions/v1/generate-sitemap/YOUR_API_KEY'
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: '9999px',
108
- padding: '8px 14px',
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" }, orderedLocales.map((locale) => (React.createElement("button", { key: locale, style: flagButtonStyles(locale), onClick: (e) => {
145
- e.stopPropagation();
146
- if (locale === currentLocale) {
147
- setIsOpen(false);
148
- }
149
- else {
150
- onLocaleChange(locale);
151
- setIsOpen(false);
152
- }
153
- }, onMouseEnter: (e) => {
154
- if (locale !== currentLocale) {
155
- e.currentTarget.style.filter = 'brightness(1.3)';
156
- }
157
- e.currentTarget.style.transform = 'scale(1.1)';
158
- }, onMouseLeave: (e) => {
159
- e.currentTarget.style.filter = 'brightness(1)';
160
- e.currentTarget.style.transform = 'scale(1)';
161
- }, "aria-label": `Switch to ${locale.toUpperCase()}`, title: locale.toUpperCase(), tabIndex: isOpen ? 0 : -1 }, LANGUAGE_FLAGS[locale] || '🏳️')))))));
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
  };
@@ -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
  }
@@ -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.11",
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",