@dabalabs/lang 0.0.1-beta → 0.0.3-beta
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/dist/dabalang.cjs +229 -10
- package/dist/dabalang.cjs.map +1 -1
- package/dist/dabalang.d.cts +38 -0
- package/dist/dabalang.d.ts +38 -0
- package/dist/dabalang.js +229 -10
- package/dist/dabalang.js.map +1 -1
- package/dist/dabalang.min.js +14 -8
- package/dist/dabalang.min.js.map +1 -1
- package/package.json +1 -1
package/dist/dabalang.cjs
CHANGED
|
@@ -97,7 +97,10 @@ async function fetchProjectMetadata(gatewayUrl, projectId, apiKey) {
|
|
|
97
97
|
code: l.code,
|
|
98
98
|
provider: l.provider,
|
|
99
99
|
pathPrefix: l.path_prefix,
|
|
100
|
-
isDefault: l.is_default
|
|
100
|
+
isDefault: l.is_default,
|
|
101
|
+
// Absent means an older gateway that has no readiness concept —
|
|
102
|
+
// treat as ready rather than hiding every language.
|
|
103
|
+
ready: l.ready ?? true
|
|
101
104
|
}))
|
|
102
105
|
};
|
|
103
106
|
}
|
|
@@ -206,13 +209,38 @@ var TranslationApplier = class {
|
|
|
206
209
|
this.apiKey = apiKey;
|
|
207
210
|
this.fetchedLanguages = /* @__PURE__ */ new Map();
|
|
208
211
|
this.activeLang = null;
|
|
209
|
-
|
|
212
|
+
/** The true source text of every node the widget has ever crawled.
|
|
213
|
+
*
|
|
214
|
+
* Needed because a re-crawl reads the DOM as it stands, and after a
|
|
215
|
+
* translation the DOM no longer holds the source. On a single-page app
|
|
216
|
+
* the shell — header, nav, footer — stays mounted across a navigation
|
|
217
|
+
* with its text already translated, so a naive re-crawl would record
|
|
218
|
+
* "Начать" as the source for the Get Started link. Everything
|
|
219
|
+
* downstream then works from a corrupted map: the widget pays to
|
|
220
|
+
* translate Russian into Russian, and restoreOriginal() writes the
|
|
221
|
+
* Russian back as if it were the original, leaving the shell stuck in a
|
|
222
|
+
* language the visitor just switched out of.
|
|
223
|
+
*
|
|
224
|
+
* Weak so it holds no node alive: entries vanish with the nodes. */
|
|
225
|
+
this.sourceByNode = /* @__PURE__ */ new WeakMap();
|
|
226
|
+
this.groups = this.absorb(crawlVisibleText(root));
|
|
227
|
+
}
|
|
228
|
+
/** Groups a crawl, preferring each node's remembered source text over
|
|
229
|
+
* whatever it currently displays, and remembering the rest. */
|
|
230
|
+
absorb(crawled) {
|
|
231
|
+
const corrected = crawled.map(({ node, sourceText }) => {
|
|
232
|
+
const known = this.sourceByNode.get(node);
|
|
233
|
+
if (known !== void 0) return { node, sourceText: known };
|
|
234
|
+
this.sourceByNode.set(node, sourceText);
|
|
235
|
+
return { node, sourceText };
|
|
236
|
+
});
|
|
237
|
+
return groupByText(corrected);
|
|
210
238
|
}
|
|
211
239
|
/** Re-crawls the current DOM — call after content is known to have
|
|
212
240
|
* changed (e.g. after a route change in an SPA). Not called
|
|
213
241
|
* automatically; dabalang does one crawl per page load. */
|
|
214
242
|
recrawl() {
|
|
215
|
-
this.groups =
|
|
243
|
+
this.groups = this.absorb(crawlVisibleText(this.root));
|
|
216
244
|
}
|
|
217
245
|
currentLanguage() {
|
|
218
246
|
return this.activeLang;
|
|
@@ -434,6 +462,97 @@ var InlineEditor = class {
|
|
|
434
462
|
}
|
|
435
463
|
};
|
|
436
464
|
|
|
465
|
+
// src/dom/navigation-watcher.ts
|
|
466
|
+
var SETTLE_MS = 250;
|
|
467
|
+
var FOLLOW_UP_PASSES = 3;
|
|
468
|
+
var subscribers = /* @__PURE__ */ new Set();
|
|
469
|
+
var unpatchHistory = null;
|
|
470
|
+
function subscribeToHistory(fn) {
|
|
471
|
+
subscribers.add(fn);
|
|
472
|
+
if (!unpatchHistory) {
|
|
473
|
+
const original = {
|
|
474
|
+
pushState: window.history.pushState,
|
|
475
|
+
replaceState: window.history.replaceState
|
|
476
|
+
};
|
|
477
|
+
for (const method of ["pushState", "replaceState"]) {
|
|
478
|
+
window.history[method] = function(...args) {
|
|
479
|
+
const result = original[method].apply(this, args);
|
|
480
|
+
for (const sub of [...subscribers]) sub();
|
|
481
|
+
return result;
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
unpatchHistory = () => {
|
|
485
|
+
window.history.pushState = original.pushState;
|
|
486
|
+
window.history.replaceState = original.replaceState;
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return () => {
|
|
490
|
+
subscribers.delete(fn);
|
|
491
|
+
if (subscribers.size === 0 && unpatchHistory) {
|
|
492
|
+
unpatchHistory();
|
|
493
|
+
unpatchHistory = null;
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
var NavigationWatcher = class {
|
|
498
|
+
constructor(onNavigated) {
|
|
499
|
+
this.onNavigated = onNavigated;
|
|
500
|
+
this.observer = null;
|
|
501
|
+
this.timer = null;
|
|
502
|
+
this.lastPath = "";
|
|
503
|
+
this.passesLeft = 0;
|
|
504
|
+
this.restorers = [];
|
|
505
|
+
}
|
|
506
|
+
start() {
|
|
507
|
+
if (typeof window === "undefined" || this.observer) return;
|
|
508
|
+
this.lastPath = this.currentPath();
|
|
509
|
+
this.restorers.push(subscribeToHistory(() => this.onRouteMaybeChanged()));
|
|
510
|
+
const onPop = () => this.onRouteMaybeChanged();
|
|
511
|
+
window.addEventListener("popstate", onPop);
|
|
512
|
+
this.restorers.push(() => window.removeEventListener("popstate", onPop));
|
|
513
|
+
this.observer = new MutationObserver(() => {
|
|
514
|
+
if (this.currentPath() !== this.lastPath) {
|
|
515
|
+
this.schedule();
|
|
516
|
+
} else if (this.passesLeft > 0) {
|
|
517
|
+
this.schedule();
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
this.observer.observe(document.body, { childList: true, subtree: true });
|
|
521
|
+
}
|
|
522
|
+
currentPath() {
|
|
523
|
+
return window.location.pathname + window.location.search;
|
|
524
|
+
}
|
|
525
|
+
onRouteMaybeChanged() {
|
|
526
|
+
if (this.currentPath() === this.lastPath) return;
|
|
527
|
+
this.schedule();
|
|
528
|
+
}
|
|
529
|
+
/** Debounced: a route change produces a burst of mutations, and the
|
|
530
|
+
* handler must run once, after the last one. */
|
|
531
|
+
schedule() {
|
|
532
|
+
if (this.timer) clearTimeout(this.timer);
|
|
533
|
+
this.timer = setTimeout(() => {
|
|
534
|
+
this.timer = null;
|
|
535
|
+
const path = this.currentPath();
|
|
536
|
+
if (path !== this.lastPath) {
|
|
537
|
+
this.lastPath = path;
|
|
538
|
+
this.passesLeft = FOLLOW_UP_PASSES;
|
|
539
|
+
this.onNavigated();
|
|
540
|
+
} else if (this.passesLeft > 0) {
|
|
541
|
+
this.passesLeft -= 1;
|
|
542
|
+
this.onNavigated();
|
|
543
|
+
}
|
|
544
|
+
}, SETTLE_MS);
|
|
545
|
+
}
|
|
546
|
+
stop() {
|
|
547
|
+
this.observer?.disconnect();
|
|
548
|
+
this.observer = null;
|
|
549
|
+
if (this.timer) clearTimeout(this.timer);
|
|
550
|
+
this.timer = null;
|
|
551
|
+
this.passesLeft = 0;
|
|
552
|
+
while (this.restorers.length) this.restorers.pop()?.();
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
437
556
|
// src/path-routing.ts
|
|
438
557
|
var PathRouter = class {
|
|
439
558
|
constructor(languages) {
|
|
@@ -477,6 +596,26 @@ var PathRouter = class {
|
|
|
477
596
|
}
|
|
478
597
|
};
|
|
479
598
|
|
|
599
|
+
// src/preference.ts
|
|
600
|
+
var PREFIX = "dabalang:lang:";
|
|
601
|
+
function readPreferredLanguage(projectId) {
|
|
602
|
+
try {
|
|
603
|
+
return window.localStorage.getItem(PREFIX + projectId);
|
|
604
|
+
} catch {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
function writePreferredLanguage(projectId, langCode) {
|
|
609
|
+
try {
|
|
610
|
+
if (langCode === null) {
|
|
611
|
+
window.localStorage.removeItem(PREFIX + projectId);
|
|
612
|
+
} else {
|
|
613
|
+
window.localStorage.setItem(PREFIX + projectId, langCode);
|
|
614
|
+
}
|
|
615
|
+
} catch {
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
480
619
|
// src/ui/flag-svgs.ts
|
|
481
620
|
var FLAG_IMAGES = {
|
|
482
621
|
us: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAAtCAMAAADFqPh+AAACClBMVEUZL13jrrHw0tQaMF7nurzsxsj46uv///+9PUT03t++rLjhqKv68PCcOkkfNWEwQ20lOmUcMV8dMmAvQ2wxRW4nPGc6TXXR1d9hcJAeNGE4S3MeM2A3SnI2SXEkOWV+iqQtQWs+UHdwfptndZQrP2o8T3Zba4s5THQoPWi+xNFSYoUgNmJKW39jcpEhNmPx8vVPYINZaYozR29ebY1reZc9UHbh5OpYaIkpPmiYordfb49IWX5MXYGTnbPS1t98iaM0R3BVZYcwRG4uQmwyRm8sQGqbpLh/i6WosMEqPmnn6e6Qm7FDVXtNXoLV2eFCVXqAjKU1SHE5THMmOmZod5XQ1d6apLiMl64iN2OGkqqHk6vc3+Z9iqTJztmcpbnN0tyPmrBUZIbf4ui5j5w/UXjNydO+nqnIu8UbMV7AxtNWZojCp7N4haD9/f7o6u6NmK8bMV+1vMvZ3eTM0dv8/f16hqGiq75sepccMl+eqLvr7fHL0Nr7/PyUnrNzgZ2WoLU7TnVldJJGWH3k5+yEkKmCjqeRm7G7ws/X2+Owt8dgb497iKJOX4JBU3nx8/Xi5etAUnh5haDHzNi6wc9ebo6OmbDFy9b09fd0gZ3Fsb2ep7onO2dicZAjOGSmrsBHWX3U2OFxf5tEVnustMW3vsyIk6tJWn9RYoTCyNR3hJ+oscJygJydprrny88smqUCAAAACXBIWXMAAC4jAAAuIwF4pT92AAACiUlEQVRIx9WURXMbQRCFOxk7tgMLkqyILLAtiy2DZEuKZWaMmSnMzMzMzMzwH/NGqcptd6uyB5Xf4aup3p6dhukh0tSafCXhY5nAXSwc21KcvY5/Bk61zaUb2hlR3e4yuO7dIxKVP3loJyqKdsDQES1SP3lTnIzkdphAr0cGPR5A8PpAn1egjauUlInNdrCYhz8aJpKnAm1Epgc3EU1wNMi/rs1TEj4ysp+uBAfGWsCRzwB7bAFtYzZQdbO0Kw4Pt0vkITS8JApZmpCz39UIQ6PLT5tXKwm+9ePw8jytA13TTkTy6SOWhd08leLuQvWCiSKZKeFsA0W3zIb+GoQEp5gQ1DcTDR04DKZqcLicnnUTVdVPIpWmu02al8RAhoYgeM/VAj56RjJj92uxdMw4QNqSoyRckvQOnOJN3sZ/Ks6ibBMN7604PInGUThZpVpttn09vIpqesEzJ2XwzRwg9JeAJf2Cep+dEq0jJ+OUJmTDB5IkesusQsYgWGlrgZIyBTNfvASej/ahYC/etaC901eQSu2dWs2CFZIhEgbPWdxgMEWv7ezVLSwHbwyC6lO1vxinXAj08RSvomChuR4+VYFqGKoDGlPVfhRove4BI0fwH5riFbR2VoKVnVbamaskPhisPObHMAzHnIyONUcExkIjpfuISTEJZvVqExlPXOYhnhoAj18bJuqq6eFdP1ShOZImsttsYKupCzSbyV/OfD4sjc1GkJ4vVxJR27cZJPql/isfotlmPEM/fuGGOeYzl2TeoV6wyQgvWDrOH5NxfsO+/+Q3bGERXFzQnKr/fnpXauq3cs55OqRvc64OUb4OZXFzgQ5lsVU5OrRUW7VCh7LYqmU6tERb9Qf3Y5Nqb/wo6gAAAABJRU5ErkJggg==",
|
|
@@ -631,10 +770,14 @@ function injectStyles() {
|
|
|
631
770
|
display: inline-flex;
|
|
632
771
|
align-items: center;
|
|
633
772
|
gap: 6px;
|
|
634
|
-
border: 1px solid rgba(0, 0, 0, 0.12);
|
|
635
|
-
background: #ffffff;
|
|
636
|
-
color: rgba(0, 0, 0, 0.75);
|
|
637
|
-
|
|
773
|
+
border: 1px solid var(--dabalang-border-color, rgba(0, 0, 0, 0.12));
|
|
774
|
+
background: var(--dabalang-bg, #ffffff);
|
|
775
|
+
color: var(--dabalang-color, rgba(0, 0, 0, 0.75));
|
|
776
|
+
/* Themeable so the switcher can match the host's own buttons. A widget
|
|
777
|
+
cannot know whether a site uses pills or 6px corners, and defaulting
|
|
778
|
+
to a pill made this the one control on the page that did not match.
|
|
779
|
+
Set --dabalang-radius on any ancestor to align it. */
|
|
780
|
+
border-radius: var(--dabalang-radius, 999px);
|
|
638
781
|
padding: 6px 12px;
|
|
639
782
|
cursor: pointer;
|
|
640
783
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
@@ -901,8 +1044,10 @@ function injectStyles() {
|
|
|
901
1044
|
max-height: 320px;
|
|
902
1045
|
overflow-y: auto;
|
|
903
1046
|
background: #ffffff;
|
|
904
|
-
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
905
|
-
|
|
1047
|
+
border: 1px solid var(--dabalang-border-color, rgba(0, 0, 0, 0.1));
|
|
1048
|
+
/* Panel corners follow the trigger's family, one step softer, so a
|
|
1049
|
+
square-cornered host does not get a pill-shaped dropdown. */
|
|
1050
|
+
border-radius: var(--dabalang-panel-radius, 14px);
|
|
906
1051
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14);
|
|
907
1052
|
padding: 6px;
|
|
908
1053
|
box-sizing: border-box;
|
|
@@ -1229,6 +1374,9 @@ var DabaLang = class {
|
|
|
1229
1374
|
this.switcher = null;
|
|
1230
1375
|
this.metadata = null;
|
|
1231
1376
|
this.pathRouter = null;
|
|
1377
|
+
this.navWatcher = null;
|
|
1378
|
+
/** Tail of the re-translation chain; see retranslateCurrentPage. */
|
|
1379
|
+
this.retranslating = Promise.resolve();
|
|
1232
1380
|
/** Currently active language (null = original). Tracked here rather
|
|
1233
1381
|
* than via the applier because a site-translated page (see
|
|
1234
1382
|
* isSiteTranslated) never runs the applier at all. */
|
|
@@ -1257,13 +1405,19 @@ var DabaLang = class {
|
|
|
1257
1405
|
});
|
|
1258
1406
|
this.switcher = renderLanguageSwitcher(
|
|
1259
1407
|
this.container,
|
|
1260
|
-
this.
|
|
1408
|
+
this.readyLanguages(),
|
|
1261
1409
|
this.metadata.sourceLang,
|
|
1262
1410
|
(langCode) => {
|
|
1263
1411
|
void this.selectLanguage(langCode);
|
|
1264
1412
|
},
|
|
1265
1413
|
this.options.view ?? "modal"
|
|
1266
1414
|
);
|
|
1415
|
+
if (typeof window !== "undefined") {
|
|
1416
|
+
this.navWatcher = new NavigationWatcher(() => {
|
|
1417
|
+
void this.retranslateCurrentPage();
|
|
1418
|
+
});
|
|
1419
|
+
this.navWatcher.start();
|
|
1420
|
+
}
|
|
1267
1421
|
if (this.metadata.enablePathRouting && typeof window !== "undefined") {
|
|
1268
1422
|
this.pathRouter = new PathRouter(this.metadata.languages);
|
|
1269
1423
|
const detected = this.pathRouter.detect(window.location.pathname);
|
|
@@ -1279,12 +1433,59 @@ var DabaLang = class {
|
|
|
1279
1433
|
}
|
|
1280
1434
|
}
|
|
1281
1435
|
}
|
|
1436
|
+
if (this.activeLang === null && !this.pathRouter) {
|
|
1437
|
+
const remembered = readPreferredLanguage(this.options.projectId);
|
|
1438
|
+
if (remembered) {
|
|
1439
|
+
const stillOffered = (this.metadata?.targetLanguages ?? []).includes(remembered);
|
|
1440
|
+
if (!stillOffered) {
|
|
1441
|
+
writePreferredLanguage(this.options.projectId, null);
|
|
1442
|
+
} else if (this.readyLanguages().includes(remembered)) {
|
|
1443
|
+
await this.selectLanguage(remembered, { navigate: false });
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Re-applies the active language to content that arrived after init.
|
|
1450
|
+
*
|
|
1451
|
+
* A no-op in the source language — there is nothing to apply — and on
|
|
1452
|
+
* a site-translated page, where the page already *is* the translation.
|
|
1453
|
+
*/
|
|
1454
|
+
async retranslateCurrentPage() {
|
|
1455
|
+
if (!this.applier || this.activeLang === null) return;
|
|
1456
|
+
if (this.isSiteTranslated(this.activeLang)) return;
|
|
1457
|
+
this.retranslating = this.retranslating.catch(() => void 0).then(async () => {
|
|
1458
|
+
if (!this.applier || this.activeLang === null) return;
|
|
1459
|
+
this.applier.recrawl();
|
|
1460
|
+
try {
|
|
1461
|
+
await this.applier.applyLanguage(this.activeLang);
|
|
1462
|
+
} catch (err) {
|
|
1463
|
+
this.handleError(err);
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
await this.retranslating;
|
|
1282
1467
|
}
|
|
1283
1468
|
/** True when the site serves this language's content itself (CMS,
|
|
1284
1469
|
* localized build): a pre-translated ("developer") language. Combined
|
|
1285
1470
|
* with a prefix match, the widget never machine-translates that page.
|
|
1286
1471
|
* (A prefix-less developer language instead swaps its uploaded
|
|
1287
1472
|
* catalogue in place.) */
|
|
1473
|
+
/** Target languages that actually have translations behind them.
|
|
1474
|
+
*
|
|
1475
|
+
* A configured-but-unpopulated language is worse than a missing one: a
|
|
1476
|
+
* visitor picks it and watches the page translate live, paragraph by
|
|
1477
|
+
* paragraph, or lands on a half-translated page while a run is still
|
|
1478
|
+
* going. Languages with no config row at all are legacy
|
|
1479
|
+
* target_languages entries that predate per-language config — they are
|
|
1480
|
+
* shown, because hiding a language that has been serving traffic would
|
|
1481
|
+
* be a regression. */
|
|
1482
|
+
readyLanguages() {
|
|
1483
|
+
const configs = this.metadata?.languages ?? [];
|
|
1484
|
+
return (this.metadata?.targetLanguages ?? []).filter((code) => {
|
|
1485
|
+
const config = configs.find((l) => l.code === code);
|
|
1486
|
+
return config ? config.ready : true;
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1288
1489
|
isSiteTranslated(langCode) {
|
|
1289
1490
|
const config = this.metadata?.languages.find((l) => l.code === langCode);
|
|
1290
1491
|
return config?.provider === "developer";
|
|
@@ -1310,6 +1511,7 @@ var DabaLang = class {
|
|
|
1310
1511
|
}
|
|
1311
1512
|
this.activeLang = langCode;
|
|
1312
1513
|
this.switcher.setActive(langCode);
|
|
1514
|
+
writePreferredLanguage(this.options.projectId, langCode);
|
|
1313
1515
|
this.options.onLanguageChange?.(langCode ?? this.metadata?.sourceLang ?? "");
|
|
1314
1516
|
} catch (err) {
|
|
1315
1517
|
this.handleError(err);
|
|
@@ -1332,10 +1534,27 @@ var DabaLang = class {
|
|
|
1332
1534
|
whenReady() {
|
|
1333
1535
|
return this.ready;
|
|
1334
1536
|
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Switch language programmatically — the same path the switcher takes,
|
|
1539
|
+
* so the choice is applied, persisted and reported identically.
|
|
1540
|
+
*
|
|
1541
|
+
* Exists because a host page often has its own reason to change
|
|
1542
|
+
* language: a footer link, a country picker, a preference synced from
|
|
1543
|
+
* an account. Without it those would have to fake a click on the
|
|
1544
|
+
* widget's own DOM.
|
|
1545
|
+
*
|
|
1546
|
+
* Pass null to return to the source language.
|
|
1547
|
+
*/
|
|
1548
|
+
async setLanguage(langCode) {
|
|
1549
|
+
await this.ready;
|
|
1550
|
+
await this.selectLanguage(langCode);
|
|
1551
|
+
}
|
|
1335
1552
|
currentLanguage() {
|
|
1336
1553
|
return this.activeLang;
|
|
1337
1554
|
}
|
|
1338
1555
|
destroy() {
|
|
1556
|
+
this.navWatcher?.stop();
|
|
1557
|
+
this.navWatcher = null;
|
|
1339
1558
|
this.editor?.detach();
|
|
1340
1559
|
this.switcher?.destroy();
|
|
1341
1560
|
this.docLang.restore();
|