@lovalingo/lovalingo 0.0.29 → 0.1.1
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 +72 -175
- package/dist/components/AixsterProvider.js +19 -11
- package/dist/components/LanguageSwitcher.d.ts +1 -0
- package/dist/components/LanguageSwitcher.js +41 -20
- package/dist/types.d.ts +1 -3
- package/dist/utils/api.js +7 -6
- package/dist/utils/logger.d.ts +3 -0
- package/dist/utils/logger.js +23 -0
- package/dist/utils/markerEngine.d.ts +21 -0
- package/dist/utils/markerEngine.js +279 -0
- package/dist/utils/pathNormalizer.js +2 -1
- package/dist/utils/translator.js +23 -21
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
# @lovalingo/lovalingo
|
|
1
|
+
# @lovalingo/lovalingo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lovalingo is a translation runtime for React (React Router) and Next.js that loads **pre-built artifacts** (JSON bundles + DOM rules) from the Lovalingo backend and applies them with **zero-flash**.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
It does **not** generate translations in the browser.
|
|
6
|
+
|
|
7
|
+
## How it works (high level)
|
|
8
|
+
|
|
9
|
+
1. Your app renders normally (source language).
|
|
10
|
+
2. Lovalingo loads the current locale’s bundle from the backend.
|
|
11
|
+
3. The runtime mutates the DOM before paint (and keeps up with route changes + dynamic content).
|
|
12
|
+
4. Optional: Lovalingo fetches DOM rules (CSS/JS/DOM patches) to fix edge cases (hidden UI, wrapping, etc.).
|
|
13
|
+
5. Optional SEO: Lovalingo updates `<head>` (canonical + hreflang + basic meta) using `seo-bundle`.
|
|
14
|
+
|
|
15
|
+
All artifacts are produced server-side by the pipeline (render → audit → deterministic translate → optional fix loop).
|
|
6
16
|
|
|
7
17
|
## Installation
|
|
8
18
|
|
|
@@ -10,108 +20,75 @@ If you ship with Lovable, v0, Emergent, Vite, Claude Code or similar tools, Lova
|
|
|
10
20
|
npm install @lovalingo/lovalingo react-router-dom
|
|
11
21
|
```
|
|
12
22
|
|
|
13
|
-
##
|
|
23
|
+
## React Router
|
|
14
24
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
### Option 1: Path Mode (Recommended - Automatic Language URLs)
|
|
18
|
-
|
|
19
|
-
**One-line setup** that automatically handles language routing like `/en/pricing`, `/fr/pricing`, `/de/pricing`:
|
|
25
|
+
### Query mode (default)
|
|
20
26
|
|
|
21
27
|
```tsx
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { useRef } from 'react';
|
|
25
|
-
|
|
26
|
-
function App() {
|
|
27
|
-
const navigateRef = useRef();
|
|
28
|
+
import { BrowserRouter } from "react-router-dom";
|
|
29
|
+
import { LovalingoProvider } from "@lovalingo/lovalingo";
|
|
28
30
|
|
|
31
|
+
export function App() {
|
|
29
32
|
return (
|
|
30
|
-
<
|
|
33
|
+
<BrowserRouter>
|
|
31
34
|
<LovalingoProvider
|
|
32
35
|
publicAnonKey="aix_your_public_anon_key"
|
|
33
36
|
defaultLocale="en"
|
|
34
|
-
locales={[
|
|
35
|
-
routing="
|
|
36
|
-
navigateRef={navigateRef}
|
|
37
|
+
locales={["en", "de", "fr"]}
|
|
38
|
+
routing="query"
|
|
37
39
|
>
|
|
38
|
-
<
|
|
39
|
-
{/* No language prefix needed! */}
|
|
40
|
-
<Route path="/" element={<Home />} />
|
|
41
|
-
<Route path="home" element={<Home />} />
|
|
42
|
-
<Route path="pricing" element={<Pricing />} />
|
|
43
|
-
<Route path="about" element={<About />} />
|
|
44
|
-
</Routes>
|
|
40
|
+
<YourApp />
|
|
45
41
|
</LovalingoProvider>
|
|
46
|
-
</
|
|
42
|
+
</BrowserRouter>
|
|
47
43
|
);
|
|
48
44
|
}
|
|
49
45
|
```
|
|
50
46
|
|
|
51
|
-
|
|
47
|
+
URLs look like: `/pricing?t=de`.
|
|
52
48
|
|
|
53
|
-
|
|
54
|
-
- on an older `@lovalingo/lovalingo` version (upgrade), or
|
|
55
|
-
- you accidentally placed `<LovalingoProvider>` directly inside `<Routes>`.
|
|
49
|
+
### Path mode (SEO-friendly URLs)
|
|
56
50
|
|
|
57
|
-
Correct:
|
|
58
51
|
```tsx
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Incorrect:
|
|
65
|
-
```tsx
|
|
66
|
-
<Routes>
|
|
67
|
-
<LovalingoProvider ... />
|
|
68
|
-
</Routes>
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
URLs automatically work as:
|
|
72
|
-
- `/en/`, `/en/home`, `/en/pricing`, `/en/about`
|
|
73
|
-
- `/fr/`, `/fr/home`, `/fr/pricing`, `/fr/about`
|
|
74
|
-
- `/de/`, `/de/home`, `/de/pricing`, `/de/about`
|
|
52
|
+
import { useRef } from "react";
|
|
53
|
+
import { Routes, Route } from "react-router-dom";
|
|
54
|
+
import { LangRouter, LovalingoProvider } from "@lovalingo/lovalingo";
|
|
75
55
|
|
|
76
|
-
|
|
56
|
+
export function App() {
|
|
57
|
+
const navigateRef = useRef<((path: string) => void) | undefined>(undefined);
|
|
77
58
|
|
|
78
|
-
Use query parameters like `/pricing?t=fr`:
|
|
79
|
-
|
|
80
|
-
```tsx
|
|
81
|
-
import { LovalingoProvider } from '@lovalingo/lovalingo';
|
|
82
|
-
import { BrowserRouter } from 'react-router-dom';
|
|
83
|
-
|
|
84
|
-
function App() {
|
|
85
59
|
return (
|
|
86
|
-
<
|
|
60
|
+
<LangRouter defaultLang="en" langs={["en", "de", "fr"]} navigateRef={navigateRef}>
|
|
87
61
|
<LovalingoProvider
|
|
88
62
|
publicAnonKey="aix_your_public_anon_key"
|
|
89
63
|
defaultLocale="en"
|
|
90
|
-
locales={[
|
|
91
|
-
routing="
|
|
64
|
+
locales={["en", "de", "fr"]}
|
|
65
|
+
routing="path"
|
|
66
|
+
navigateRef={navigateRef}
|
|
92
67
|
>
|
|
93
|
-
<
|
|
68
|
+
<Routes>
|
|
69
|
+
<Route path="/" element={<Home />} />
|
|
70
|
+
<Route path="pricing" element={<Pricing />} />
|
|
71
|
+
<Route path="about" element={<About />} />
|
|
72
|
+
</Routes>
|
|
94
73
|
</LovalingoProvider>
|
|
95
|
-
</
|
|
74
|
+
</LangRouter>
|
|
96
75
|
);
|
|
97
76
|
}
|
|
98
77
|
```
|
|
99
78
|
|
|
100
|
-
|
|
79
|
+
URLs look like: `/de/pricing`.
|
|
80
|
+
|
|
81
|
+
## Next.js (App Router)
|
|
101
82
|
|
|
102
83
|
```tsx
|
|
103
84
|
// app/layout.tsx
|
|
104
|
-
import { LovalingoProvider } from
|
|
85
|
+
import { LovalingoProvider } from "@lovalingo/lovalingo";
|
|
105
86
|
|
|
106
|
-
export default function RootLayout({ children }) {
|
|
87
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
107
88
|
return (
|
|
108
89
|
<html>
|
|
109
90
|
<body>
|
|
110
|
-
<LovalingoProvider
|
|
111
|
-
publicAnonKey="aix_your_public_anon_key"
|
|
112
|
-
defaultLocale="en"
|
|
113
|
-
locales={['en', 'de', 'fr', 'es']}
|
|
114
|
-
>
|
|
91
|
+
<LovalingoProvider publicAnonKey="aix_your_public_anon_key" defaultLocale="en" locales={["en", "de", "fr"]}>
|
|
115
92
|
{children}
|
|
116
93
|
</LovalingoProvider>
|
|
117
94
|
</body>
|
|
@@ -120,124 +97,27 @@ export default function RootLayout({ children }) {
|
|
|
120
97
|
}
|
|
121
98
|
```
|
|
122
99
|
|
|
123
|
-
##
|
|
124
|
-
|
|
125
|
-
```tsx
|
|
126
|
-
<LovalingoProvider
|
|
127
|
-
publicAnonKey="aix_xxx" // Required: Your Lovalingo Public Anon Key (safe to expose)
|
|
128
|
-
defaultLocale="en" // Required: Source language
|
|
129
|
-
locales={['en', 'de', 'fr']} // Required: Supported languages
|
|
130
|
-
apiBase="https://..." // Optional: Custom API endpoint
|
|
131
|
-
routing="query" // Optional: 'query' | 'path' (default: 'query')
|
|
132
|
-
autoPrefixLinks={true} // Optional (path mode): keep locale when the app renders absolute links like "/pricing"
|
|
133
|
-
switcherPosition="bottom-right" // Optional: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
|
|
134
|
-
switcherOffsetY={20} // Optional: Vertical offset in pixels
|
|
135
|
-
editMode={false} // Optional: Enable edit mode
|
|
136
|
-
editKey="KeyE" // Optional: Keyboard shortcut for edit mode
|
|
137
|
-
mode="dom" // Optional: 'dom' | 'context' (default: 'dom')
|
|
138
|
-
pathNormalization={{ enabled: true }}// Optional: normalize paths for stable translation keys (default: enabled)
|
|
139
|
-
seo={true} // Optional: auto canonical + hreflang + sitemap link (default: true)
|
|
140
|
-
sitemap={true} // Optional: auto-inject sitemap link (default: true)
|
|
141
|
-
>
|
|
142
|
-
{children}
|
|
143
|
-
</LovalingoProvider>
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
### Optional: Inject via `index.html`
|
|
100
|
+
## SEO (canonical + hreflang + meta)
|
|
147
101
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
```html
|
|
151
|
-
<meta name="lovalingo-public-anon-key" content="aix_xxx" />
|
|
152
|
-
<script>
|
|
153
|
-
window.__LOVALINGO_PUBLIC_ANON_KEY__ = "aix_xxx";
|
|
154
|
-
</script>
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
Then you may omit `publicAnonKey` and Lovalingo will read it from the meta tag or `window.__LOVALINGO_PUBLIC_ANON_KEY__`.
|
|
158
|
-
|
|
159
|
-
### Routing Modes
|
|
160
|
-
|
|
161
|
-
- **`query`** (default): Language in query parameter `/pricing?t=fr`
|
|
162
|
-
- Use with standard React Router setup
|
|
163
|
-
- No URL structure changes needed
|
|
164
|
-
|
|
165
|
-
- **`path`**: Language in URL path `/fr/pricing`
|
|
166
|
-
- Use with `<LangRouter>` wrapper
|
|
167
|
-
- Automatic language routing
|
|
168
|
-
- SEO-optimized URLs
|
|
169
|
-
- Non-localized by default: `/auth`, `/login`, `/signup` (no `/:lang/` prefix)
|
|
170
|
-
- Examples:
|
|
171
|
-
- `/en/` → English home
|
|
172
|
-
- `/de/pricing` → German pricing
|
|
173
|
-
- `/fr/about` → French about
|
|
174
|
-
|
|
175
|
-
## Path Mode Helpers (v0.0.x+)
|
|
176
|
-
|
|
177
|
-
### LangLink - Language-Aware Links
|
|
102
|
+
Enabled by default. Disable if you already manage `<head>` yourself:
|
|
178
103
|
|
|
179
104
|
```tsx
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
function Navigation() {
|
|
183
|
-
return (
|
|
184
|
-
<nav>
|
|
185
|
-
{/* Automatically becomes /en/pricing or /fr/pricing */}
|
|
186
|
-
<LangLink to="pricing">Pricing</LangLink>
|
|
187
|
-
<LangLink to="about">About</LangLink>
|
|
188
|
-
</nav>
|
|
189
|
-
);
|
|
190
|
-
}
|
|
105
|
+
<LovalingoProvider seo={false} ... />
|
|
191
106
|
```
|
|
192
107
|
|
|
193
|
-
|
|
108
|
+
## Sitemap link tag
|
|
194
109
|
|
|
195
|
-
|
|
196
|
-
import { useLang } from '@lovalingo/lovalingo';
|
|
197
|
-
|
|
198
|
-
function MyComponent() {
|
|
199
|
-
const lang = useLang(); // 'en', 'fr', 'de', etc.
|
|
200
|
-
return <div>Current language: {lang}</div>;
|
|
201
|
-
}
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
### useLangNavigate - Programmatic Navigation
|
|
110
|
+
By default Lovalingo injects `<link rel="sitemap" href="/sitemap.xml">` for discovery. Disable it:
|
|
205
111
|
|
|
206
112
|
```tsx
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
function MyComponent() {
|
|
210
|
-
const navigate = useLangNavigate();
|
|
211
|
-
|
|
212
|
-
const goToPricing = () => {
|
|
213
|
-
navigate('pricing'); // Goes to /en/pricing or /fr/pricing automatically
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
return <button onClick={goToPricing}>View Pricing</button>;
|
|
217
|
-
}
|
|
113
|
+
<LovalingoProvider sitemap={false} ... />
|
|
218
114
|
```
|
|
219
115
|
|
|
220
|
-
|
|
116
|
+
You still need to serve `/sitemap.xml` on your own domain (recommended: reverse-proxy to Lovalingo’s `generate-sitemap` endpoint).
|
|
221
117
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
Access locale state and switching:
|
|
225
|
-
|
|
226
|
-
```tsx
|
|
227
|
-
import { useLovalingo } from '@lovalingo/lovalingo';
|
|
228
|
-
|
|
229
|
-
function MyComponent() {
|
|
230
|
-
const { locale, setLocale, isLoading, config } = useLovalingo();
|
|
231
|
-
|
|
232
|
-
return (
|
|
233
|
-
<button onClick={() => setLocale('de')}>
|
|
234
|
-
Switch to German
|
|
235
|
-
</button>
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
```
|
|
118
|
+
## License
|
|
239
119
|
|
|
240
|
-
|
|
120
|
+
Commercial license (not open source). See `react-package/LICENSE`.
|
|
241
121
|
|
|
242
122
|
Manual translation control:
|
|
243
123
|
|
|
@@ -295,6 +175,23 @@ function MyComponent() {
|
|
|
295
175
|
- ✅ RTL ready: automatically sets `<html dir="rtl">` for `ar/he/fa/ur`
|
|
296
176
|
- ✅ Optional Context Mode: `<AutoTranslate>` with hash-based caching for React text nodes
|
|
297
177
|
|
|
178
|
+
## Language Switcher
|
|
179
|
+
|
|
180
|
+
Lovalingo includes a floating language switcher.
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
<LovalingoProvider
|
|
184
|
+
publicAnonKey="aix_xxx"
|
|
185
|
+
defaultLocale="en"
|
|
186
|
+
locales={["en", "de", "fr"]}
|
|
187
|
+
switcherPosition="bottom-right"
|
|
188
|
+
switcherOffsetY={20}
|
|
189
|
+
switcherTheme="light" // "dark" | "light" (default: "dark")
|
|
190
|
+
>
|
|
191
|
+
<App />
|
|
192
|
+
</LovalingoProvider>
|
|
193
|
+
```
|
|
194
|
+
|
|
298
195
|
## SEO (Canonical + hreflang)
|
|
299
196
|
|
|
300
197
|
Lovalingo can keep `<head>` SEO signals in sync with the active locale:
|
|
@@ -3,11 +3,13 @@ import { LovalingoContext } from '../context/LovalingoContext';
|
|
|
3
3
|
import { LovalingoAPI } from '../utils/api';
|
|
4
4
|
import { Translator } from '../utils/translator';
|
|
5
5
|
import { applyDomRules } from '../utils/domRules';
|
|
6
|
+
import { startMarkerEngine } from '../utils/markerEngine';
|
|
7
|
+
import { logDebug, warnDebug, errorDebug } from '../utils/logger';
|
|
6
8
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
7
9
|
import { NavigationOverlay } from './NavigationOverlay';
|
|
8
10
|
const LOCALE_STORAGE_KEY = 'Lovalingo_locale';
|
|
9
11
|
export const LovalingoProvider = ({ children, apiKey: apiKeyProp, publicAnonKey, defaultLocale, locales, apiBase = 'https://leuskvkajliuzalrlwhw.supabase.co', routing = 'query', // Default to query mode (backward compatible)
|
|
10
|
-
autoPrefixLinks = true, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
|
|
12
|
+
autoPrefixLinks = true, autoApplyRules = true, switcherPosition = 'bottom-right', switcherOffsetY = 20, switcherTheme = 'dark', editMode: initialEditMode = false, editKey = 'KeyE', pathNormalization = { enabled: true }, // Enable by default
|
|
11
13
|
mode = 'dom', // Default to legacy DOM mode for backward compatibility
|
|
12
14
|
sitemap = true, // Default: true - Auto-inject sitemap link tag
|
|
13
15
|
seo = true, // Default: true - Can be disabled per project entitlements
|
|
@@ -73,6 +75,7 @@ navigateRef, // For path mode routing
|
|
|
73
75
|
autoPrefixLinks,
|
|
74
76
|
switcherPosition,
|
|
75
77
|
switcherOffsetY,
|
|
78
|
+
switcherTheme,
|
|
76
79
|
editMode: initialEditMode,
|
|
77
80
|
editKey,
|
|
78
81
|
pathNormalization,
|
|
@@ -204,9 +207,14 @@ navigateRef, // For path mode routing
|
|
|
204
207
|
}
|
|
205
208
|
}
|
|
206
209
|
catch (e) {
|
|
207
|
-
|
|
210
|
+
warnDebug("[Lovalingo] updateSeoLinks() failed:", e);
|
|
208
211
|
}
|
|
209
212
|
}, [isSeoActive]);
|
|
213
|
+
// Marker engine: always mark full DOM content for deterministic pipeline extraction.
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
const stop = startMarkerEngine({ throttleMs: 120 });
|
|
216
|
+
return () => stop();
|
|
217
|
+
}, []);
|
|
210
218
|
// Detect locale from URL or localStorage
|
|
211
219
|
const detectLocale = useCallback(() => {
|
|
212
220
|
// 1. Check URL first based on routing mode
|
|
@@ -234,7 +242,7 @@ navigateRef, // For path mode routing
|
|
|
234
242
|
}
|
|
235
243
|
catch (e) {
|
|
236
244
|
// localStorage might be unavailable (SSR, private browsing)
|
|
237
|
-
|
|
245
|
+
warnDebug('localStorage not available:', e);
|
|
238
246
|
}
|
|
239
247
|
// 3. Default locale
|
|
240
248
|
return defaultLocale;
|
|
@@ -282,7 +290,7 @@ navigateRef, // For path mode routing
|
|
|
282
290
|
const cachedDomRules = domRulesCacheRef.current.get(cacheKey);
|
|
283
291
|
if (cachedEntry && cachedExclusions) {
|
|
284
292
|
// CACHE HIT - Use cached data immediately (FAST!)
|
|
285
|
-
|
|
293
|
+
logDebug(`[Lovalingo] Using cached translations for ${targetLocale} on ${currentPath}`);
|
|
286
294
|
translatorRef.current.setTranslations(cachedEntry.translations);
|
|
287
295
|
translatorRef.current.setExclusions(cachedExclusions);
|
|
288
296
|
if (mode === 'dom') {
|
|
@@ -304,7 +312,7 @@ navigateRef, // For path mode routing
|
|
|
304
312
|
if (isNavigatingRef.current) {
|
|
305
313
|
return;
|
|
306
314
|
}
|
|
307
|
-
|
|
315
|
+
logDebug(`[Lovalingo] 🔄 Retry scan for late-rendering content`);
|
|
308
316
|
if (mode === 'dom') {
|
|
309
317
|
translatorRef.current.translateDOM();
|
|
310
318
|
}
|
|
@@ -320,11 +328,11 @@ navigateRef, // For path mode routing
|
|
|
320
328
|
return;
|
|
321
329
|
}
|
|
322
330
|
// CACHE MISS - Fetch from API
|
|
323
|
-
|
|
331
|
+
logDebug(`[Lovalingo] Fetching translations for ${targetLocale} on ${currentPath}`);
|
|
324
332
|
setIsLoading(true);
|
|
325
333
|
try {
|
|
326
334
|
if (previousLocale && previousLocale !== defaultLocale) {
|
|
327
|
-
|
|
335
|
+
logDebug(`[Lovalingo] Switching from ${previousLocale} to ${targetLocale}`);
|
|
328
336
|
}
|
|
329
337
|
const [bundle, exclusions, domRules] = await Promise.all([
|
|
330
338
|
apiRef.current.fetchBundle(targetLocale),
|
|
@@ -362,7 +370,7 @@ navigateRef, // For path mode routing
|
|
|
362
370
|
if (isNavigatingRef.current) {
|
|
363
371
|
return;
|
|
364
372
|
}
|
|
365
|
-
|
|
373
|
+
logDebug(`[Lovalingo] 🔄 Retry scan for late-rendering content`);
|
|
366
374
|
if (mode === "dom") {
|
|
367
375
|
translatorRef.current.translateDOM();
|
|
368
376
|
}
|
|
@@ -376,7 +384,7 @@ navigateRef, // For path mode routing
|
|
|
376
384
|
}
|
|
377
385
|
}
|
|
378
386
|
catch (error) {
|
|
379
|
-
|
|
387
|
+
errorDebug('Error loading translations:', error);
|
|
380
388
|
if (showOverlay)
|
|
381
389
|
setIsNavigationLoading(false);
|
|
382
390
|
}
|
|
@@ -396,7 +404,7 @@ navigateRef, // For path mode routing
|
|
|
396
404
|
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
|
|
397
405
|
}
|
|
398
406
|
catch (e) {
|
|
399
|
-
|
|
407
|
+
warnDebug('Failed to save locale to localStorage:', e);
|
|
400
408
|
}
|
|
401
409
|
isInternalNavigationRef.current = true;
|
|
402
410
|
// Show navigation overlay immediately (only when a non-default locale is involved)
|
|
@@ -788,7 +796,7 @@ navigateRef, // For path mode routing
|
|
|
788
796
|
};
|
|
789
797
|
return (React.createElement(LovalingoContext.Provider, { value: contextValue },
|
|
790
798
|
children,
|
|
791
|
-
React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, branding: entitlements?.brandingRequired
|
|
799
|
+
React.createElement(LanguageSwitcher, { locales: allLocales, currentLocale: locale, onLocaleChange: setLocale, position: switcherPosition, offsetY: switcherOffsetY, theme: switcherTheme, branding: entitlements?.brandingRequired
|
|
792
800
|
? { required: true, href: "https://lovalingo.com" }
|
|
793
801
|
: undefined }),
|
|
794
802
|
React.createElement(NavigationOverlay, { isVisible: isNavigationLoading })));
|
|
@@ -22,12 +22,39 @@ const LANGUAGE_FLAGS = {
|
|
|
22
22
|
no: '🇳🇴',
|
|
23
23
|
fi: '🇫🇮',
|
|
24
24
|
};
|
|
25
|
-
export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, position = 'bottom-right', offsetY = 20, branding, }) => {
|
|
25
|
+
export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, position = 'bottom-right', offsetY = 20, theme = 'dark', branding, }) => {
|
|
26
26
|
const [isOpen, setIsOpen] = useState(false);
|
|
27
27
|
const containerRef = useRef(null);
|
|
28
28
|
const isRight = position.endsWith('right');
|
|
29
29
|
const isTop = position.startsWith('top');
|
|
30
30
|
const isBottom = position.startsWith('bottom');
|
|
31
|
+
const tokens = theme === 'light'
|
|
32
|
+
? {
|
|
33
|
+
surfaceBg: 'rgba(255, 255, 255, 0.93)',
|
|
34
|
+
surfaceHoverBg: 'rgba(245, 245, 245, 0.95)',
|
|
35
|
+
text: 'rgba(13, 13, 13, 0.96)',
|
|
36
|
+
textMuted: 'rgba(13, 13, 13, 0.74)',
|
|
37
|
+
divider: 'rgba(0, 0, 0, 0.10)',
|
|
38
|
+
insetHighlight: 'inset 0 0 1px rgba(0, 0, 0, 0.10)',
|
|
39
|
+
tabShadowRight: '-2px 0 8px rgba(0, 0, 0, 0.12), inset 0 0 1px rgba(0, 0, 0, 0.10)',
|
|
40
|
+
tabShadowLeft: '2px 0 8px rgba(0, 0, 0, 0.12), inset 0 0 1px rgba(0, 0, 0, 0.10)',
|
|
41
|
+
tabHoverShadowRight: '-4px 0 16px rgba(0, 0, 0, 0.16), inset 0 0 1px rgba(0, 0, 0, 0.12)',
|
|
42
|
+
tabHoverShadowLeft: '4px 0 16px rgba(0, 0, 0, 0.16), inset 0 0 1px rgba(0, 0, 0, 0.12)',
|
|
43
|
+
panelShadow: '0 8px 24px rgba(0, 0, 0, 0.14), inset 0 0 1px rgba(0, 0, 0, 0.10)',
|
|
44
|
+
}
|
|
45
|
+
: {
|
|
46
|
+
surfaceBg: 'rgba(26, 26, 26, 0.93)',
|
|
47
|
+
surfaceHoverBg: 'rgba(35, 35, 35, 0.95)',
|
|
48
|
+
text: 'rgba(255, 255, 255, 0.98)',
|
|
49
|
+
textMuted: 'rgba(255, 255, 255, 0.82)',
|
|
50
|
+
divider: 'rgba(255, 255, 255, 0.12)',
|
|
51
|
+
insetHighlight: 'inset 0 0 1px rgba(255, 255, 255, 0.10)',
|
|
52
|
+
tabShadowRight: '-2px 0 8px rgba(0, 0, 0, 0.2), inset 0 0 1px rgba(255, 255, 255, 0.10)',
|
|
53
|
+
tabShadowLeft: '2px 0 8px rgba(0, 0, 0, 0.2), inset 0 0 1px rgba(255, 255, 255, 0.10)',
|
|
54
|
+
tabHoverShadowRight: '-4px 0 16px rgba(0, 0, 0, 0.3), inset 0 0 1px rgba(255, 255, 255, 0.15)',
|
|
55
|
+
tabHoverShadowLeft: '4px 0 16px rgba(0, 0, 0, 0.3), inset 0 0 1px rgba(255, 255, 255, 0.15)',
|
|
56
|
+
panelShadow: '0 8px 24px rgba(0, 0, 0, 0.3), inset 0 0 1px rgba(255, 255, 255, 0.10)',
|
|
57
|
+
};
|
|
31
58
|
// Order locales: active first, then others
|
|
32
59
|
const orderedLocales = [
|
|
33
60
|
currentLocale,
|
|
@@ -80,17 +107,15 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
|
|
|
80
107
|
width: '44px',
|
|
81
108
|
height: '50px',
|
|
82
109
|
borderRadius: isRight ? '12px 0 0 12px' : '0 12px 12px 0',
|
|
83
|
-
background:
|
|
110
|
+
background: tokens.surfaceBg,
|
|
84
111
|
backdropFilter: 'blur(12px)',
|
|
85
|
-
boxShadow: isRight
|
|
86
|
-
? '-2px 0 8px rgba(0, 0, 0, 0.2), inset 0 0 1px rgba(255, 255, 255, 0.1)'
|
|
87
|
-
: '2px 0 8px rgba(0, 0, 0, 0.2), inset 0 0 1px rgba(255, 255, 255, 0.1)',
|
|
112
|
+
boxShadow: isRight ? tokens.tabShadowRight : tokens.tabShadowLeft,
|
|
88
113
|
cursor: 'pointer',
|
|
89
114
|
fontSize: '20px',
|
|
90
115
|
transition: 'all 0.2s ease',
|
|
91
116
|
userSelect: 'none',
|
|
92
117
|
border: 'none',
|
|
93
|
-
color:
|
|
118
|
+
color: tokens.text,
|
|
94
119
|
};
|
|
95
120
|
const panelStyles = {
|
|
96
121
|
position: 'absolute',
|
|
@@ -102,14 +127,14 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
|
|
|
102
127
|
: `translateY(-50%) translateX(${isRight ? '12px' : '-12px'})`,
|
|
103
128
|
opacity: isOpen ? 1 : 0,
|
|
104
129
|
pointerEvents: isOpen ? 'auto' : 'none',
|
|
105
|
-
background:
|
|
130
|
+
background: tokens.surfaceBg,
|
|
106
131
|
backdropFilter: 'blur(12px)',
|
|
107
132
|
borderRadius: '16px',
|
|
108
133
|
padding: '10px 12px',
|
|
109
134
|
display: 'flex',
|
|
110
135
|
flexDirection: 'column',
|
|
111
136
|
gap: '10px',
|
|
112
|
-
boxShadow:
|
|
137
|
+
boxShadow: tokens.panelShadow,
|
|
113
138
|
transition: 'opacity 0.25s ease, transform 0.25s ease',
|
|
114
139
|
};
|
|
115
140
|
const localeRowStyles = {
|
|
@@ -122,14 +147,14 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
|
|
|
122
147
|
alignItems: 'center',
|
|
123
148
|
gap: '8px',
|
|
124
149
|
paddingTop: '8px',
|
|
125
|
-
borderTop:
|
|
150
|
+
borderTop: `1px solid ${tokens.divider}`,
|
|
126
151
|
fontSize: '12px',
|
|
127
|
-
color:
|
|
152
|
+
color: tokens.textMuted,
|
|
128
153
|
userSelect: 'none',
|
|
129
154
|
whiteSpace: 'nowrap',
|
|
130
155
|
};
|
|
131
156
|
const badgeLinkStyles = {
|
|
132
|
-
color:
|
|
157
|
+
color: tokens.text,
|
|
133
158
|
textDecoration: 'none',
|
|
134
159
|
display: 'inline-flex',
|
|
135
160
|
alignItems: 'center',
|
|
@@ -155,15 +180,11 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
|
|
|
155
180
|
return (React.createElement("div", { ref: containerRef, style: containerStyles, "data-Lovalingo-exclude": "true" },
|
|
156
181
|
React.createElement("div", { style: rootStyles },
|
|
157
182
|
React.createElement("button", { style: tabStyles, onClick: () => setIsOpen(!isOpen), onMouseEnter: (e) => {
|
|
158
|
-
e.currentTarget.style.background =
|
|
159
|
-
e.currentTarget.style.boxShadow = isRight
|
|
160
|
-
? '-4px 0 16px rgba(0, 0, 0, 0.3), inset 0 0 1px rgba(255, 255, 255, 0.15)'
|
|
161
|
-
: '4px 0 16px rgba(0, 0, 0, 0.3), inset 0 0 1px rgba(255, 255, 255, 0.15)';
|
|
183
|
+
e.currentTarget.style.background = tokens.surfaceHoverBg;
|
|
184
|
+
e.currentTarget.style.boxShadow = isRight ? tokens.tabHoverShadowRight : tokens.tabHoverShadowLeft;
|
|
162
185
|
}, onMouseLeave: (e) => {
|
|
163
|
-
e.currentTarget.style.background =
|
|
164
|
-
e.currentTarget.style.boxShadow = isRight
|
|
165
|
-
? '-2px 0 8px rgba(0, 0, 0, 0.2), inset 0 0 1px rgba(255, 255, 255, 0.1)'
|
|
166
|
-
: '2px 0 8px rgba(0, 0, 0, 0.2), inset 0 0 1px rgba(255, 255, 255, 0.1)';
|
|
186
|
+
e.currentTarget.style.background = tokens.surfaceBg;
|
|
187
|
+
e.currentTarget.style.boxShadow = isRight ? tokens.tabShadowRight : tokens.tabShadowLeft;
|
|
167
188
|
}, "aria-label": "Open language switcher", "aria-expanded": isOpen }, LANGUAGE_FLAGS[currentLocale] || '🌐'),
|
|
168
189
|
React.createElement("div", { style: panelStyles, role: "toolbar", "aria-label": "Language options" },
|
|
169
190
|
React.createElement("div", { style: localeRowStyles }, orderedLocales.map((locale) => (React.createElement("button", { key: locale, style: flagButtonStyles(locale), onClick: (e) => {
|
|
@@ -202,5 +223,5 @@ export const LanguageSwitcher = ({ locales, currentLocale, onLocaleChange, posit
|
|
|
202
223
|
React.createElement("span", null,
|
|
203
224
|
branding.label || 'Localized by',
|
|
204
225
|
" ",
|
|
205
|
-
React.createElement("strong", { style: { color:
|
|
226
|
+
React.createElement("strong", { "data-no-translate": true, style: { color: tokens.text } }, "Lovalingo")))))))));
|
|
206
227
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -4,9 +4,6 @@ export interface LovalingoConfig {
|
|
|
4
4
|
* Public project key (safe to expose in the browser).
|
|
5
5
|
*/
|
|
6
6
|
publicAnonKey?: string;
|
|
7
|
-
/**
|
|
8
|
-
* @deprecated Use `publicAnonKey`.
|
|
9
|
-
*/
|
|
10
7
|
apiKey?: string;
|
|
11
8
|
defaultLocale: string;
|
|
12
9
|
locales: string[];
|
|
@@ -15,6 +12,7 @@ export interface LovalingoConfig {
|
|
|
15
12
|
autoPrefixLinks?: boolean;
|
|
16
13
|
switcherPosition?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
17
14
|
switcherOffsetY?: number;
|
|
15
|
+
switcherTheme?: 'dark' | 'light';
|
|
18
16
|
editMode?: boolean;
|
|
19
17
|
editKey?: string;
|
|
20
18
|
pathNormalization?: PathNormalizationConfig;
|
package/dist/utils/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { processPath } from './pathNormalizer';
|
|
2
|
+
import { warnDebug, errorDebug } from './logger';
|
|
2
3
|
export class LovalingoAPI {
|
|
3
4
|
constructor(apiKey, apiBase, pathConfig) {
|
|
4
5
|
this.entitlements = null;
|
|
@@ -11,10 +12,10 @@ export class LovalingoAPI {
|
|
|
11
12
|
}
|
|
12
13
|
warnMissingApiKey(action) {
|
|
13
14
|
// Avoid hard-crashing apps; make the failure mode obvious.
|
|
14
|
-
|
|
15
|
+
warnDebug(`[Lovalingo] Missing public project key: ${action} was skipped. Pass publicAnonKey to <LovalingoProvider ...> (or set VITE_LOVALINGO_PUBLIC_ANON_KEY).`);
|
|
15
16
|
}
|
|
16
17
|
logActivationRequired(context, response) {
|
|
17
|
-
|
|
18
|
+
errorDebug(`[Lovalingo] ${context} blocked (HTTP ${response.status}). ` +
|
|
18
19
|
`This project is not activated yet. ` +
|
|
19
20
|
`Publish a public manifest at "/.well-known/lovalingo.json" on your domain, ` +
|
|
20
21
|
`then verify it in the Lovalingo dashboard to activate translations + SEO.`);
|
|
@@ -129,7 +130,7 @@ export class LovalingoAPI {
|
|
|
129
130
|
}));
|
|
130
131
|
}
|
|
131
132
|
catch (error) {
|
|
132
|
-
|
|
133
|
+
errorDebug('Error fetching translations:', error);
|
|
133
134
|
return [];
|
|
134
135
|
}
|
|
135
136
|
}
|
|
@@ -184,7 +185,7 @@ export class LovalingoAPI {
|
|
|
184
185
|
return Array.isArray(data.exclusions) ? data.exclusions : [];
|
|
185
186
|
}
|
|
186
187
|
catch (error) {
|
|
187
|
-
|
|
188
|
+
errorDebug('Error fetching exclusions:', error);
|
|
188
189
|
return [];
|
|
189
190
|
}
|
|
190
191
|
}
|
|
@@ -210,7 +211,7 @@ export class LovalingoAPI {
|
|
|
210
211
|
return Array.isArray(data?.rules) ? data.rules : [];
|
|
211
212
|
}
|
|
212
213
|
catch (error) {
|
|
213
|
-
|
|
214
|
+
errorDebug('Error fetching DOM rules:', error);
|
|
214
215
|
return [];
|
|
215
216
|
}
|
|
216
217
|
}
|
|
@@ -230,7 +231,7 @@ export class LovalingoAPI {
|
|
|
230
231
|
}
|
|
231
232
|
}
|
|
232
233
|
catch (error) {
|
|
233
|
-
|
|
234
|
+
errorDebug('Error saving exclusion:', error);
|
|
234
235
|
throw error;
|
|
235
236
|
}
|
|
236
237
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function isDebugEnabled() {
|
|
2
|
+
if (typeof globalThis === "undefined")
|
|
3
|
+
return false;
|
|
4
|
+
const value = globalThis.__lovalingoDebug;
|
|
5
|
+
if (value === true || value === "true" || value === 1)
|
|
6
|
+
return true;
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
export function logDebug(...args) {
|
|
10
|
+
if (!isDebugEnabled())
|
|
11
|
+
return;
|
|
12
|
+
console.log(...args);
|
|
13
|
+
}
|
|
14
|
+
export function warnDebug(...args) {
|
|
15
|
+
if (!isDebugEnabled())
|
|
16
|
+
return;
|
|
17
|
+
console.warn(...args);
|
|
18
|
+
}
|
|
19
|
+
export function errorDebug(...args) {
|
|
20
|
+
if (!isDebugEnabled())
|
|
21
|
+
return;
|
|
22
|
+
console.error(...args);
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type MarkerStats = {
|
|
2
|
+
totalTextNodes: number;
|
|
3
|
+
markedNodes: number;
|
|
4
|
+
skippedUnsafeNodes: number;
|
|
5
|
+
skippedExcludedNodes: number;
|
|
6
|
+
skippedNonTranslatableNodes: number;
|
|
7
|
+
totalChars: number;
|
|
8
|
+
markedChars: number;
|
|
9
|
+
skippedUnsafeChars: number;
|
|
10
|
+
skippedExcludedChars: number;
|
|
11
|
+
skippedNonTranslatableChars: number;
|
|
12
|
+
coverageRatio: number;
|
|
13
|
+
coverageRatioChars: number;
|
|
14
|
+
};
|
|
15
|
+
type MarkerEngineOptions = {
|
|
16
|
+
throttleMs?: number;
|
|
17
|
+
};
|
|
18
|
+
export declare function startMarkerEngine(options?: MarkerEngineOptions): typeof stopMarkerEngine;
|
|
19
|
+
export declare function stopMarkerEngine(): void;
|
|
20
|
+
export declare function getMarkerStats(): MarkerStats;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { hashContent } from "./hash";
|
|
2
|
+
const DEFAULT_THROTTLE_MS = 150;
|
|
3
|
+
const EXCLUDE_SELECTOR = "[data-lovalingo-exclude],[data-notranslate],[translate-no],[data-no-translate]";
|
|
4
|
+
const MARKER_SELECTOR = "[data-lovalingo-original]";
|
|
5
|
+
const UNSAFE_CONTAINER_TAGS = new Set(["script", "style", "noscript", "template", "svg", "canvas"]);
|
|
6
|
+
const DIRECT_MARK_TAGS = new Set(["option", "textarea"]);
|
|
7
|
+
const ATTRIBUTE_MARKS = [
|
|
8
|
+
{ attr: "title", marker: "data-lovalingo-title-original" },
|
|
9
|
+
{ attr: "aria-label", marker: "data-lovalingo-aria-label-original" },
|
|
10
|
+
{ attr: "placeholder", marker: "data-lovalingo-placeholder-original" },
|
|
11
|
+
];
|
|
12
|
+
const unsafeSelector = Array.from(UNSAFE_CONTAINER_TAGS).join(",");
|
|
13
|
+
let observer = null;
|
|
14
|
+
let scheduled = null;
|
|
15
|
+
let running = false;
|
|
16
|
+
let lastStats = buildEmptyStats();
|
|
17
|
+
let throttleMs = DEFAULT_THROTTLE_MS;
|
|
18
|
+
function buildEmptyStats() {
|
|
19
|
+
return {
|
|
20
|
+
totalTextNodes: 0,
|
|
21
|
+
markedNodes: 0,
|
|
22
|
+
skippedUnsafeNodes: 0,
|
|
23
|
+
skippedExcludedNodes: 0,
|
|
24
|
+
skippedNonTranslatableNodes: 0,
|
|
25
|
+
totalChars: 0,
|
|
26
|
+
markedChars: 0,
|
|
27
|
+
skippedUnsafeChars: 0,
|
|
28
|
+
skippedExcludedChars: 0,
|
|
29
|
+
skippedNonTranslatableChars: 0,
|
|
30
|
+
coverageRatio: 0,
|
|
31
|
+
coverageRatioChars: 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function setGlobalStats(stats) {
|
|
35
|
+
lastStats = stats;
|
|
36
|
+
if (typeof window === "undefined")
|
|
37
|
+
return;
|
|
38
|
+
window.__lovalingoMarkersReady = true;
|
|
39
|
+
window.__lovalingoMarkerStats = stats;
|
|
40
|
+
}
|
|
41
|
+
function isExcludedElement(el) {
|
|
42
|
+
if (!el)
|
|
43
|
+
return false;
|
|
44
|
+
return Boolean(el.closest(EXCLUDE_SELECTOR));
|
|
45
|
+
}
|
|
46
|
+
function findUnsafeContainer(el) {
|
|
47
|
+
if (!el)
|
|
48
|
+
return null;
|
|
49
|
+
if (!unsafeSelector)
|
|
50
|
+
return null;
|
|
51
|
+
return el.closest(unsafeSelector);
|
|
52
|
+
}
|
|
53
|
+
function getStableKey(el) {
|
|
54
|
+
const owner = el.closest("[data-lovalingo-key]");
|
|
55
|
+
const key = owner?.getAttribute("data-lovalingo-key") || "";
|
|
56
|
+
return key.trim();
|
|
57
|
+
}
|
|
58
|
+
function getElementIndex(el) {
|
|
59
|
+
const parent = el.parentElement;
|
|
60
|
+
if (!parent)
|
|
61
|
+
return 0;
|
|
62
|
+
const children = Array.from(parent.children);
|
|
63
|
+
const idx = children.indexOf(el);
|
|
64
|
+
return idx >= 0 ? idx : 0;
|
|
65
|
+
}
|
|
66
|
+
function getTextNodeIndex(node) {
|
|
67
|
+
let index = 0;
|
|
68
|
+
let prev = node.previousSibling;
|
|
69
|
+
while (prev) {
|
|
70
|
+
if (prev.nodeType === Node.TEXT_NODE)
|
|
71
|
+
index += 1;
|
|
72
|
+
prev = prev.previousSibling;
|
|
73
|
+
}
|
|
74
|
+
return index;
|
|
75
|
+
}
|
|
76
|
+
function buildElementPath(el) {
|
|
77
|
+
const parts = [];
|
|
78
|
+
let current = el;
|
|
79
|
+
while (current && current.tagName && current !== document.body) {
|
|
80
|
+
const tag = current.tagName.toLowerCase();
|
|
81
|
+
const idx = getElementIndex(current);
|
|
82
|
+
parts.push(`${tag}[${idx}]`);
|
|
83
|
+
current = current.parentElement;
|
|
84
|
+
}
|
|
85
|
+
parts.push("body");
|
|
86
|
+
return parts.reverse().join("/");
|
|
87
|
+
}
|
|
88
|
+
function isTranslatableText(text) {
|
|
89
|
+
if (!text || text.trim().length < 2)
|
|
90
|
+
return false;
|
|
91
|
+
if (/^(__[A-Z0-9_]+__\s*)+$/.test(text))
|
|
92
|
+
return false;
|
|
93
|
+
if (/^\d+(\.\d+)?$/.test(text))
|
|
94
|
+
return false;
|
|
95
|
+
if (!/[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]/.test(text))
|
|
96
|
+
return false;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
function buildStableId(el, text, textIndex) {
|
|
100
|
+
const key = getStableKey(el);
|
|
101
|
+
const path = buildElementPath(el);
|
|
102
|
+
const raw = `${path}#text[${textIndex}]|${text.trim()}|${key}`;
|
|
103
|
+
return hashContent(raw);
|
|
104
|
+
}
|
|
105
|
+
function markElementDirect(el, rawText, stats) {
|
|
106
|
+
if (!el.hasAttribute("data-lovalingo-original")) {
|
|
107
|
+
el.setAttribute("data-lovalingo-original", rawText.trim());
|
|
108
|
+
}
|
|
109
|
+
if (!el.hasAttribute("data-lovalingo-id")) {
|
|
110
|
+
el.setAttribute("data-lovalingo-id", buildStableId(el, rawText, 0));
|
|
111
|
+
}
|
|
112
|
+
el.setAttribute("data-lovalingo-kind", "text");
|
|
113
|
+
stats.markedNodes += 1;
|
|
114
|
+
stats.markedChars += rawText.length;
|
|
115
|
+
}
|
|
116
|
+
function markTextNode(node, stats) {
|
|
117
|
+
const raw = node.nodeValue || "";
|
|
118
|
+
if (!raw)
|
|
119
|
+
return;
|
|
120
|
+
const trimmed = raw.trim();
|
|
121
|
+
if (!trimmed)
|
|
122
|
+
return;
|
|
123
|
+
stats.totalTextNodes += 1;
|
|
124
|
+
stats.totalChars += raw.length;
|
|
125
|
+
const parent = node.parentElement;
|
|
126
|
+
if (!parent)
|
|
127
|
+
return;
|
|
128
|
+
if (isExcludedElement(parent)) {
|
|
129
|
+
stats.skippedExcludedNodes += 1;
|
|
130
|
+
stats.skippedExcludedChars += raw.length;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const unsafe = findUnsafeContainer(parent);
|
|
134
|
+
if (unsafe) {
|
|
135
|
+
stats.skippedUnsafeNodes += 1;
|
|
136
|
+
stats.skippedUnsafeChars += raw.length;
|
|
137
|
+
if (!unsafe.hasAttribute("data-lovalingo-unsafe")) {
|
|
138
|
+
unsafe.setAttribute("data-lovalingo-unsafe", unsafe.tagName.toLowerCase());
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!isTranslatableText(trimmed)) {
|
|
143
|
+
stats.skippedNonTranslatableNodes += 1;
|
|
144
|
+
stats.skippedNonTranslatableChars += raw.length;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const existingMarker = parent.closest(MARKER_SELECTOR);
|
|
148
|
+
if (existingMarker) {
|
|
149
|
+
if (!existingMarker.getAttribute("data-lovalingo-id")) {
|
|
150
|
+
const textIndex = getTextNodeIndex(node);
|
|
151
|
+
existingMarker.setAttribute("data-lovalingo-id", buildStableId(parent, raw, textIndex));
|
|
152
|
+
}
|
|
153
|
+
stats.markedNodes += 1;
|
|
154
|
+
stats.markedChars += raw.length;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const parentTag = parent.tagName.toLowerCase();
|
|
158
|
+
if (DIRECT_MARK_TAGS.has(parentTag)) {
|
|
159
|
+
markElementDirect(parent, raw, stats);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const wrapper = document.createElement("span");
|
|
163
|
+
wrapper.setAttribute("data-lovalingo-original", trimmed);
|
|
164
|
+
wrapper.setAttribute("data-lovalingo-id", buildStableId(parent, raw, getTextNodeIndex(node)));
|
|
165
|
+
wrapper.setAttribute("data-lovalingo-kind", "text");
|
|
166
|
+
wrapper.setAttribute("data-lovalingo-marker", "1");
|
|
167
|
+
wrapper.textContent = raw;
|
|
168
|
+
try {
|
|
169
|
+
parent.replaceChild(wrapper, node);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
stats.markedNodes += 1;
|
|
175
|
+
stats.markedChars += raw.length;
|
|
176
|
+
}
|
|
177
|
+
function markAttributes(root) {
|
|
178
|
+
const nodes = root.querySelectorAll("[title],[aria-label],[placeholder]");
|
|
179
|
+
nodes.forEach((el) => {
|
|
180
|
+
if (isExcludedElement(el))
|
|
181
|
+
return;
|
|
182
|
+
if (findUnsafeContainer(el))
|
|
183
|
+
return;
|
|
184
|
+
for (const { attr, marker } of ATTRIBUTE_MARKS) {
|
|
185
|
+
if (el.hasAttribute(marker))
|
|
186
|
+
continue;
|
|
187
|
+
const value = el.getAttribute(attr);
|
|
188
|
+
if (!value)
|
|
189
|
+
continue;
|
|
190
|
+
const trimmed = value.trim();
|
|
191
|
+
if (!trimmed || !isTranslatableText(trimmed))
|
|
192
|
+
continue;
|
|
193
|
+
el.setAttribute(marker, trimmed);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function finalizeStats(stats) {
|
|
198
|
+
const eligibleNodes = stats.totalTextNodes -
|
|
199
|
+
stats.skippedUnsafeNodes -
|
|
200
|
+
stats.skippedExcludedNodes -
|
|
201
|
+
stats.skippedNonTranslatableNodes;
|
|
202
|
+
const eligibleChars = stats.totalChars -
|
|
203
|
+
stats.skippedUnsafeChars -
|
|
204
|
+
stats.skippedExcludedChars -
|
|
205
|
+
stats.skippedNonTranslatableChars;
|
|
206
|
+
stats.coverageRatio = eligibleNodes > 0 ? stats.markedNodes / eligibleNodes : 1;
|
|
207
|
+
stats.coverageRatioChars = eligibleChars > 0 ? stats.markedChars / eligibleChars : 1;
|
|
208
|
+
}
|
|
209
|
+
function scanAndMark() {
|
|
210
|
+
if (!running)
|
|
211
|
+
return;
|
|
212
|
+
const root = document.body;
|
|
213
|
+
if (!root) {
|
|
214
|
+
setGlobalStats(buildEmptyStats());
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const stats = buildEmptyStats();
|
|
218
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
219
|
+
const nodes = [];
|
|
220
|
+
let node = walker.nextNode();
|
|
221
|
+
while (node) {
|
|
222
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
223
|
+
nodes.push(node);
|
|
224
|
+
node = walker.nextNode();
|
|
225
|
+
}
|
|
226
|
+
nodes.forEach((textNode) => markTextNode(textNode, stats));
|
|
227
|
+
markAttributes(root);
|
|
228
|
+
finalizeStats(stats);
|
|
229
|
+
setGlobalStats(stats);
|
|
230
|
+
}
|
|
231
|
+
function scheduleScan() {
|
|
232
|
+
if (!running)
|
|
233
|
+
return;
|
|
234
|
+
if (scheduled != null)
|
|
235
|
+
return;
|
|
236
|
+
scheduled = window.setTimeout(() => {
|
|
237
|
+
scheduled = null;
|
|
238
|
+
scanAndMark();
|
|
239
|
+
}, throttleMs);
|
|
240
|
+
}
|
|
241
|
+
export function startMarkerEngine(options = {}) {
|
|
242
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
243
|
+
return () => undefined;
|
|
244
|
+
}
|
|
245
|
+
stopMarkerEngine();
|
|
246
|
+
running = true;
|
|
247
|
+
throttleMs = Math.max(20, options.throttleMs ?? DEFAULT_THROTTLE_MS);
|
|
248
|
+
const startObserver = () => {
|
|
249
|
+
if (!running)
|
|
250
|
+
return;
|
|
251
|
+
if (!document.body) {
|
|
252
|
+
window.setTimeout(startObserver, 50);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
observer = new MutationObserver(() => scheduleScan());
|
|
256
|
+
observer.observe(document.body, {
|
|
257
|
+
childList: true,
|
|
258
|
+
subtree: true,
|
|
259
|
+
characterData: true,
|
|
260
|
+
});
|
|
261
|
+
scanAndMark();
|
|
262
|
+
};
|
|
263
|
+
startObserver();
|
|
264
|
+
return stopMarkerEngine;
|
|
265
|
+
}
|
|
266
|
+
export function stopMarkerEngine() {
|
|
267
|
+
running = false;
|
|
268
|
+
if (scheduled != null) {
|
|
269
|
+
window.clearTimeout(scheduled);
|
|
270
|
+
scheduled = null;
|
|
271
|
+
}
|
|
272
|
+
if (observer) {
|
|
273
|
+
observer.disconnect();
|
|
274
|
+
observer = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
export function getMarkerStats() {
|
|
278
|
+
return lastStats;
|
|
279
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* /dashboard/projects/4458eb10-608c-4622-a92e-ec3ed2eeb524/setup
|
|
9
9
|
* → /dashboard/projects/:id/setup
|
|
10
10
|
*/
|
|
11
|
+
import { warnDebug } from "./logger";
|
|
11
12
|
// Common patterns for dynamic segments
|
|
12
13
|
const UUID_PATTERN = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/gi;
|
|
13
14
|
const NUMERIC_ID_PATTERN = /\/\d+(?=\/|$)/g;
|
|
@@ -40,7 +41,7 @@ export function normalizePath(path, config) {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
catch (error) {
|
|
43
|
-
|
|
44
|
+
warnDebug('[PathNormalizer] Invalid pattern:', rule.pattern, error);
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
}
|
package/dist/utils/translator.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { logDebug, warnDebug, errorDebug } from './logger';
|
|
1
2
|
export class Translator {
|
|
2
3
|
constructor() {
|
|
3
4
|
this.translationMap = new Map();
|
|
4
5
|
this.exclusions = [];
|
|
5
6
|
// Brand names that should NEVER be translated
|
|
6
7
|
this.nonTranslatableTerms = new Set([
|
|
8
|
+
'Lovalingo',
|
|
7
9
|
'Lovable', 'v0', 'Claude Code', 'Bolt', 'Base44',
|
|
8
10
|
'Replit', 'GitHub', 'Supabase', 'OpenAI', 'Anthropic'
|
|
9
11
|
]);
|
|
@@ -112,7 +114,7 @@ export class Translator {
|
|
|
112
114
|
setTranslations(translations) {
|
|
113
115
|
this.translationMap.clear();
|
|
114
116
|
if (!Array.isArray(translations)) {
|
|
115
|
-
|
|
117
|
+
errorDebug('[Lovalingo] setTranslations expected array, got:', typeof translations);
|
|
116
118
|
return;
|
|
117
119
|
}
|
|
118
120
|
translations.forEach(t => {
|
|
@@ -121,7 +123,7 @@ export class Translator {
|
|
|
121
123
|
this.translationMap.set(t.source_text.trim(), t.translated_text);
|
|
122
124
|
}
|
|
123
125
|
});
|
|
124
|
-
|
|
126
|
+
logDebug(`[Lovalingo] ✅ Loaded ${this.translationMap.size} translations`);
|
|
125
127
|
}
|
|
126
128
|
setExclusions(exclusions) {
|
|
127
129
|
this.exclusions = exclusions || [];
|
|
@@ -333,7 +335,7 @@ export class Translator {
|
|
|
333
335
|
const hadLettersInRaw = /[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]/.test(rawHTML);
|
|
334
336
|
const hadPlaceholders = Object.keys(placeholderMap).length > 0;
|
|
335
337
|
if (hadLettersInRaw || hadPlaceholders) {
|
|
336
|
-
|
|
338
|
+
warnDebug('[Lovalingo] ⚠️ Non-translatable content after extraction:', semanticElement.outerHTML.substring(0, 140));
|
|
337
339
|
}
|
|
338
340
|
return {
|
|
339
341
|
rawHTML,
|
|
@@ -342,7 +344,7 @@ export class Translator {
|
|
|
342
344
|
semanticContext
|
|
343
345
|
};
|
|
344
346
|
}
|
|
345
|
-
|
|
347
|
+
logDebug(`[Lovalingo] 📝 Extracted: "${processedText.substring(0, 80)}..." with ${Object.keys(placeholderMap).length} placeholders`);
|
|
346
348
|
return {
|
|
347
349
|
rawHTML,
|
|
348
350
|
processedText,
|
|
@@ -393,7 +395,7 @@ export class Translator {
|
|
|
393
395
|
if (SEMANTIC_BOUNDARIES.has(element.tagName)) {
|
|
394
396
|
const originalHTML = element.getAttribute('data-Lovalingo-original-html');
|
|
395
397
|
if (originalHTML) {
|
|
396
|
-
|
|
398
|
+
logDebug(`[Lovalingo] 🔄 Restoring: "${element.textContent?.substring(0, 50)}..."`);
|
|
397
399
|
// Mark current live elements before restoring
|
|
398
400
|
const markedElements = this.markElements(element);
|
|
399
401
|
// Use safe update that preserves live elements
|
|
@@ -421,11 +423,11 @@ export class Translator {
|
|
|
421
423
|
* Restore all elements to original state
|
|
422
424
|
*/
|
|
423
425
|
restoreDOM() {
|
|
424
|
-
|
|
426
|
+
logDebug('[Lovalingo] 🔄 Restoring DOM to original state...');
|
|
425
427
|
const startTime = performance.now();
|
|
426
428
|
this.restoreElement(document.body);
|
|
427
429
|
const elapsed = performance.now() - startTime;
|
|
428
|
-
|
|
430
|
+
logDebug(`[Lovalingo] 🏁 DOM restoration complete in ${elapsed.toFixed(2)}ms`);
|
|
429
431
|
}
|
|
430
432
|
/**
|
|
431
433
|
* Mark all descendant elements with unique IDs before translation
|
|
@@ -480,7 +482,7 @@ export class Translator {
|
|
|
480
482
|
updateElementChildren(parent, newHTML, markedElements) {
|
|
481
483
|
// Safety check: Ensure parent is still in the DOM
|
|
482
484
|
if (!parent.isConnected || !document.body.contains(parent)) {
|
|
483
|
-
|
|
485
|
+
warnDebug('[Lovalingo] Parent element not in DOM, skipping translation');
|
|
484
486
|
return;
|
|
485
487
|
}
|
|
486
488
|
try {
|
|
@@ -497,7 +499,7 @@ export class Translator {
|
|
|
497
499
|
}
|
|
498
500
|
catch (e) {
|
|
499
501
|
// Node might have been removed by React already, skip
|
|
500
|
-
|
|
502
|
+
warnDebug('[Lovalingo] Could not remove child, likely already removed by framework');
|
|
501
503
|
break;
|
|
502
504
|
}
|
|
503
505
|
}
|
|
@@ -507,13 +509,13 @@ export class Translator {
|
|
|
507
509
|
parent.appendChild(temp.firstChild);
|
|
508
510
|
}
|
|
509
511
|
catch (e) {
|
|
510
|
-
|
|
512
|
+
warnDebug('[Lovalingo] Could not append child:', e);
|
|
511
513
|
break;
|
|
512
514
|
}
|
|
513
515
|
}
|
|
514
516
|
}
|
|
515
517
|
catch (error) {
|
|
516
|
-
|
|
518
|
+
errorDebug('[Lovalingo] Error updating element children:', error);
|
|
517
519
|
}
|
|
518
520
|
}
|
|
519
521
|
/**
|
|
@@ -594,7 +596,7 @@ export class Translator {
|
|
|
594
596
|
const sourceText = originalTextAttr || content.processedText;
|
|
595
597
|
// If boundary isn't translatable, recurse to children.
|
|
596
598
|
if (!this.isTranslatableText(sourceText)) {
|
|
597
|
-
|
|
599
|
+
logDebug('[Lovalingo] ⏭️ Skipping non-translatable content:', sourceText.substring(0, 50));
|
|
598
600
|
Array.from(element.children).forEach((child) => {
|
|
599
601
|
if (child instanceof HTMLElement)
|
|
600
602
|
this.translateElement(child);
|
|
@@ -603,10 +605,10 @@ export class Translator {
|
|
|
603
605
|
}
|
|
604
606
|
const translated = this.translationMap.get(sourceText);
|
|
605
607
|
if (translated) {
|
|
606
|
-
|
|
608
|
+
logDebug(`[Lovalingo] ✅ Translating: "${sourceText.substring(0, 50)}..." → "${translated.substring(0, 50)}..."`);
|
|
607
609
|
let reconstructedHTML = this.reconstructHTML(translated, content.placeholderMap);
|
|
608
610
|
if (/__[A-Z0-9_]+__/.test(reconstructedHTML)) {
|
|
609
|
-
|
|
611
|
+
warnDebug('[Lovalingo] ⚠️ Tokens remain after reconstruction, using original HTML');
|
|
610
612
|
reconstructedHTML = content.rawHTML;
|
|
611
613
|
}
|
|
612
614
|
// Mark for MutationObserver suppression (prevents feedback loops on DOM updates).
|
|
@@ -624,7 +626,7 @@ export class Translator {
|
|
|
624
626
|
});
|
|
625
627
|
}
|
|
626
628
|
else {
|
|
627
|
-
|
|
629
|
+
logDebug(`[Lovalingo] ❌ Miss: "${sourceText.substring(0, 80)}..."`);
|
|
628
630
|
}
|
|
629
631
|
}
|
|
630
632
|
finally {
|
|
@@ -660,15 +662,15 @@ export class Translator {
|
|
|
660
662
|
// Use stored original text
|
|
661
663
|
const sourceText = originalTextAttr || content.processedText;
|
|
662
664
|
if (!this.isTranslatableText(sourceText)) {
|
|
663
|
-
|
|
665
|
+
logDebug('[Lovalingo] ⏭️ Skipping non-translatable content (generic):', sourceText.substring(0, 50));
|
|
664
666
|
return;
|
|
665
667
|
}
|
|
666
668
|
const translated = this.translationMap.get(sourceText);
|
|
667
669
|
if (translated) {
|
|
668
|
-
|
|
670
|
+
logDebug(`[Lovalingo] ✅ Generic semantic: "${sourceText.substring(0, 50)}..." → "${translated.substring(0, 50)}..."`);
|
|
669
671
|
let reconstructedHTML = this.reconstructHTML(translated, content.placeholderMap);
|
|
670
672
|
if (/__[A-Z0-9_]+__/.test(reconstructedHTML)) {
|
|
671
|
-
|
|
673
|
+
warnDebug('[Lovalingo] ⚠️ Tokens remain (generic), using original HTML');
|
|
672
674
|
reconstructedHTML = content.rawHTML;
|
|
673
675
|
}
|
|
674
676
|
this.updateElementChildren(element, reconstructedHTML, markedElements);
|
|
@@ -680,7 +682,7 @@ export class Translator {
|
|
|
680
682
|
});
|
|
681
683
|
}
|
|
682
684
|
else {
|
|
683
|
-
|
|
685
|
+
logDebug(`[Lovalingo] ❌ Miss (generic): "${sourceText.substring(0, 80)}..."`);
|
|
684
686
|
}
|
|
685
687
|
}
|
|
686
688
|
finally {
|
|
@@ -791,10 +793,10 @@ export class Translator {
|
|
|
791
793
|
}
|
|
792
794
|
}
|
|
793
795
|
translateDOM() {
|
|
794
|
-
|
|
796
|
+
logDebug(`[Lovalingo] 🔄 translateDOM() called with ${this.translationMap.size} translations`);
|
|
795
797
|
const startTime = performance.now();
|
|
796
798
|
this.translateElement(document.body);
|
|
797
799
|
const elapsed = performance.now() - startTime;
|
|
798
|
-
|
|
800
|
+
logDebug(`[Lovalingo] 🏁 translateDOM() complete in ${elapsed.toFixed(2)}ms.`);
|
|
799
801
|
}
|
|
800
802
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.1.1";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.1.1";
|
package/package.json
CHANGED