@dabalabs/lang 0.0.2-beta → 0.0.4-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 CHANGED
@@ -209,13 +209,38 @@ var TranslationApplier = class {
209
209
  this.apiKey = apiKey;
210
210
  this.fetchedLanguages = /* @__PURE__ */ new Map();
211
211
  this.activeLang = null;
212
- this.groups = groupByText(crawlVisibleText(root));
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);
213
238
  }
214
239
  /** Re-crawls the current DOM — call after content is known to have
215
240
  * changed (e.g. after a route change in an SPA). Not called
216
241
  * automatically; dabalang does one crawl per page load. */
217
242
  recrawl() {
218
- this.groups = groupByText(crawlVisibleText(this.root));
243
+ this.groups = this.absorb(crawlVisibleText(this.root));
219
244
  }
220
245
  currentLanguage() {
221
246
  return this.activeLang;
@@ -437,6 +462,97 @@ var InlineEditor = class {
437
462
  }
438
463
  };
439
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
+
440
556
  // src/path-routing.ts
441
557
  var PathRouter = class {
442
558
  constructor(languages) {
@@ -480,6 +596,26 @@ var PathRouter = class {
480
596
  }
481
597
  };
482
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
+
483
619
  // src/ui/flag-svgs.ts
484
620
  var FLAG_IMAGES = {
485
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==",
@@ -534,15 +670,15 @@ var FLAG_IMAGES = {
534
670
  var STYLE_ID = "dabalang-switcher-styles";
535
671
  var LANGUAGE_LABELS = {
536
672
  en: "English",
537
- es: "Espa\xF1ola",
673
+ es: "Espa\xF1ol",
538
674
  fr: "Fran\xE7ais",
539
- de: "Deutschland",
675
+ de: "Deutsch",
540
676
  pt: "Portugu\xEAs",
541
677
  it: "Italiano",
542
678
  ja: "\u65E5\u672C\u8A9E",
543
679
  ko: "\uD55C\uAD6D\uC5B4",
544
680
  zh: "\u4E2D\u6587",
545
- ar: "\u0639\u0631\u0628\u064A",
681
+ ar: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629",
546
682
  hi: "\u0939\u093F\u0928\u094D\u0926\u0940",
547
683
  ru: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439",
548
684
  nl: "Nederlands",
@@ -1238,6 +1374,9 @@ var DabaLang = class {
1238
1374
  this.switcher = null;
1239
1375
  this.metadata = null;
1240
1376
  this.pathRouter = null;
1377
+ this.navWatcher = null;
1378
+ /** Tail of the re-translation chain; see retranslateCurrentPage. */
1379
+ this.retranslating = Promise.resolve();
1241
1380
  /** Currently active language (null = original). Tracked here rather
1242
1381
  * than via the applier because a site-translated page (see
1243
1382
  * isSiteTranslated) never runs the applier at all. */
@@ -1273,6 +1412,12 @@ var DabaLang = class {
1273
1412
  },
1274
1413
  this.options.view ?? "modal"
1275
1414
  );
1415
+ if (typeof window !== "undefined") {
1416
+ this.navWatcher = new NavigationWatcher(() => {
1417
+ void this.retranslateCurrentPage();
1418
+ });
1419
+ this.navWatcher.start();
1420
+ }
1276
1421
  if (this.metadata.enablePathRouting && typeof window !== "undefined") {
1277
1422
  this.pathRouter = new PathRouter(this.metadata.languages);
1278
1423
  const detected = this.pathRouter.detect(window.location.pathname);
@@ -1288,6 +1433,37 @@ var DabaLang = class {
1288
1433
  }
1289
1434
  }
1290
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;
1291
1467
  }
1292
1468
  /** True when the site serves this language's content itself (CMS,
1293
1469
  * localized build): a pre-translated ("developer") language. Combined
@@ -1335,6 +1511,7 @@ var DabaLang = class {
1335
1511
  }
1336
1512
  this.activeLang = langCode;
1337
1513
  this.switcher.setActive(langCode);
1514
+ writePreferredLanguage(this.options.projectId, langCode);
1338
1515
  this.options.onLanguageChange?.(langCode ?? this.metadata?.sourceLang ?? "");
1339
1516
  } catch (err) {
1340
1517
  this.handleError(err);
@@ -1357,10 +1534,27 @@ var DabaLang = class {
1357
1534
  whenReady() {
1358
1535
  return this.ready;
1359
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
+ }
1360
1552
  currentLanguage() {
1361
1553
  return this.activeLang;
1362
1554
  }
1363
1555
  destroy() {
1556
+ this.navWatcher?.stop();
1557
+ this.navWatcher = null;
1364
1558
  this.editor?.detach();
1365
1559
  this.switcher?.destroy();
1366
1560
  this.docLang.restore();