@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 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: '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
  };
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';
@@ -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
  }
@@ -0,0 +1 @@
1
+ export declare const VERSION = "0.0.14";
@@ -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.12",
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",