@dabalabs/lang 0.0.2-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.
@@ -88,6 +88,9 @@ declare class DabaLang {
88
88
  private switcher;
89
89
  private metadata;
90
90
  private pathRouter;
91
+ private navWatcher;
92
+ /** Tail of the re-translation chain; see retranslateCurrentPage. */
93
+ private retranslating;
91
94
  /** Currently active language (null = original). Tracked here rather
92
95
  * than via the applier because a site-translated page (see
93
96
  * isSiteTranslated) never runs the applier at all. */
@@ -95,6 +98,13 @@ declare class DabaLang {
95
98
  private readonly ready;
96
99
  constructor(options: DabaLangOptions);
97
100
  private init;
101
+ /**
102
+ * Re-applies the active language to content that arrived after init.
103
+ *
104
+ * A no-op in the source language — there is nothing to apply — and on
105
+ * a site-translated page, where the page already *is* the translation.
106
+ */
107
+ private retranslateCurrentPage;
98
108
  /** True when the site serves this language's content itself (CMS,
99
109
  * localized build): a pre-translated ("developer") language. Combined
100
110
  * with a prefix match, the widget never machine-translates that page.
@@ -118,6 +128,18 @@ declare class DabaLang {
118
128
  /** Resolves once the initial metadata fetch + pill render has settled
119
129
  * (successfully or not) — mainly useful in tests. */
120
130
  whenReady(): Promise<void>;
131
+ /**
132
+ * Switch language programmatically — the same path the switcher takes,
133
+ * so the choice is applied, persisted and reported identically.
134
+ *
135
+ * Exists because a host page often has its own reason to change
136
+ * language: a footer link, a country picker, a preference synced from
137
+ * an account. Without it those would have to fake a click on the
138
+ * widget's own DOM.
139
+ *
140
+ * Pass null to return to the source language.
141
+ */
142
+ setLanguage(langCode: string | null): Promise<void>;
121
143
  currentLanguage(): string | null;
122
144
  destroy(): void;
123
145
  }
@@ -88,6 +88,9 @@ declare class DabaLang {
88
88
  private switcher;
89
89
  private metadata;
90
90
  private pathRouter;
91
+ private navWatcher;
92
+ /** Tail of the re-translation chain; see retranslateCurrentPage. */
93
+ private retranslating;
91
94
  /** Currently active language (null = original). Tracked here rather
92
95
  * than via the applier because a site-translated page (see
93
96
  * isSiteTranslated) never runs the applier at all. */
@@ -95,6 +98,13 @@ declare class DabaLang {
95
98
  private readonly ready;
96
99
  constructor(options: DabaLangOptions);
97
100
  private init;
101
+ /**
102
+ * Re-applies the active language to content that arrived after init.
103
+ *
104
+ * A no-op in the source language — there is nothing to apply — and on
105
+ * a site-translated page, where the page already *is* the translation.
106
+ */
107
+ private retranslateCurrentPage;
98
108
  /** True when the site serves this language's content itself (CMS,
99
109
  * localized build): a pre-translated ("developer") language. Combined
100
110
  * with a prefix match, the widget never machine-translates that page.
@@ -118,6 +128,18 @@ declare class DabaLang {
118
128
  /** Resolves once the initial metadata fetch + pill render has settled
119
129
  * (successfully or not) — mainly useful in tests. */
120
130
  whenReady(): Promise<void>;
131
+ /**
132
+ * Switch language programmatically — the same path the switcher takes,
133
+ * so the choice is applied, persisted and reported identically.
134
+ *
135
+ * Exists because a host page often has its own reason to change
136
+ * language: a footer link, a country picker, a preference synced from
137
+ * an account. Without it those would have to fake a click on the
138
+ * widget's own DOM.
139
+ *
140
+ * Pass null to return to the source language.
141
+ */
142
+ setLanguage(langCode: string | null): Promise<void>;
121
143
  currentLanguage(): string | null;
122
144
  destroy(): void;
123
145
  }
package/dist/dabalang.js CHANGED
@@ -207,13 +207,38 @@ var TranslationApplier = class {
207
207
  this.apiKey = apiKey;
208
208
  this.fetchedLanguages = /* @__PURE__ */ new Map();
209
209
  this.activeLang = null;
210
- this.groups = groupByText(crawlVisibleText(root));
210
+ /** The true source text of every node the widget has ever crawled.
211
+ *
212
+ * Needed because a re-crawl reads the DOM as it stands, and after a
213
+ * translation the DOM no longer holds the source. On a single-page app
214
+ * the shell — header, nav, footer — stays mounted across a navigation
215
+ * with its text already translated, so a naive re-crawl would record
216
+ * "Начать" as the source for the Get Started link. Everything
217
+ * downstream then works from a corrupted map: the widget pays to
218
+ * translate Russian into Russian, and restoreOriginal() writes the
219
+ * Russian back as if it were the original, leaving the shell stuck in a
220
+ * language the visitor just switched out of.
221
+ *
222
+ * Weak so it holds no node alive: entries vanish with the nodes. */
223
+ this.sourceByNode = /* @__PURE__ */ new WeakMap();
224
+ this.groups = this.absorb(crawlVisibleText(root));
225
+ }
226
+ /** Groups a crawl, preferring each node's remembered source text over
227
+ * whatever it currently displays, and remembering the rest. */
228
+ absorb(crawled) {
229
+ const corrected = crawled.map(({ node, sourceText }) => {
230
+ const known = this.sourceByNode.get(node);
231
+ if (known !== void 0) return { node, sourceText: known };
232
+ this.sourceByNode.set(node, sourceText);
233
+ return { node, sourceText };
234
+ });
235
+ return groupByText(corrected);
211
236
  }
212
237
  /** Re-crawls the current DOM — call after content is known to have
213
238
  * changed (e.g. after a route change in an SPA). Not called
214
239
  * automatically; dabalang does one crawl per page load. */
215
240
  recrawl() {
216
- this.groups = groupByText(crawlVisibleText(this.root));
241
+ this.groups = this.absorb(crawlVisibleText(this.root));
217
242
  }
218
243
  currentLanguage() {
219
244
  return this.activeLang;
@@ -435,6 +460,97 @@ var InlineEditor = class {
435
460
  }
436
461
  };
437
462
 
463
+ // src/dom/navigation-watcher.ts
464
+ var SETTLE_MS = 250;
465
+ var FOLLOW_UP_PASSES = 3;
466
+ var subscribers = /* @__PURE__ */ new Set();
467
+ var unpatchHistory = null;
468
+ function subscribeToHistory(fn) {
469
+ subscribers.add(fn);
470
+ if (!unpatchHistory) {
471
+ const original = {
472
+ pushState: window.history.pushState,
473
+ replaceState: window.history.replaceState
474
+ };
475
+ for (const method of ["pushState", "replaceState"]) {
476
+ window.history[method] = function(...args) {
477
+ const result = original[method].apply(this, args);
478
+ for (const sub of [...subscribers]) sub();
479
+ return result;
480
+ };
481
+ }
482
+ unpatchHistory = () => {
483
+ window.history.pushState = original.pushState;
484
+ window.history.replaceState = original.replaceState;
485
+ };
486
+ }
487
+ return () => {
488
+ subscribers.delete(fn);
489
+ if (subscribers.size === 0 && unpatchHistory) {
490
+ unpatchHistory();
491
+ unpatchHistory = null;
492
+ }
493
+ };
494
+ }
495
+ var NavigationWatcher = class {
496
+ constructor(onNavigated) {
497
+ this.onNavigated = onNavigated;
498
+ this.observer = null;
499
+ this.timer = null;
500
+ this.lastPath = "";
501
+ this.passesLeft = 0;
502
+ this.restorers = [];
503
+ }
504
+ start() {
505
+ if (typeof window === "undefined" || this.observer) return;
506
+ this.lastPath = this.currentPath();
507
+ this.restorers.push(subscribeToHistory(() => this.onRouteMaybeChanged()));
508
+ const onPop = () => this.onRouteMaybeChanged();
509
+ window.addEventListener("popstate", onPop);
510
+ this.restorers.push(() => window.removeEventListener("popstate", onPop));
511
+ this.observer = new MutationObserver(() => {
512
+ if (this.currentPath() !== this.lastPath) {
513
+ this.schedule();
514
+ } else if (this.passesLeft > 0) {
515
+ this.schedule();
516
+ }
517
+ });
518
+ this.observer.observe(document.body, { childList: true, subtree: true });
519
+ }
520
+ currentPath() {
521
+ return window.location.pathname + window.location.search;
522
+ }
523
+ onRouteMaybeChanged() {
524
+ if (this.currentPath() === this.lastPath) return;
525
+ this.schedule();
526
+ }
527
+ /** Debounced: a route change produces a burst of mutations, and the
528
+ * handler must run once, after the last one. */
529
+ schedule() {
530
+ if (this.timer) clearTimeout(this.timer);
531
+ this.timer = setTimeout(() => {
532
+ this.timer = null;
533
+ const path = this.currentPath();
534
+ if (path !== this.lastPath) {
535
+ this.lastPath = path;
536
+ this.passesLeft = FOLLOW_UP_PASSES;
537
+ this.onNavigated();
538
+ } else if (this.passesLeft > 0) {
539
+ this.passesLeft -= 1;
540
+ this.onNavigated();
541
+ }
542
+ }, SETTLE_MS);
543
+ }
544
+ stop() {
545
+ this.observer?.disconnect();
546
+ this.observer = null;
547
+ if (this.timer) clearTimeout(this.timer);
548
+ this.timer = null;
549
+ this.passesLeft = 0;
550
+ while (this.restorers.length) this.restorers.pop()?.();
551
+ }
552
+ };
553
+
438
554
  // src/path-routing.ts
439
555
  var PathRouter = class {
440
556
  constructor(languages) {
@@ -478,6 +594,26 @@ var PathRouter = class {
478
594
  }
479
595
  };
480
596
 
597
+ // src/preference.ts
598
+ var PREFIX = "dabalang:lang:";
599
+ function readPreferredLanguage(projectId) {
600
+ try {
601
+ return window.localStorage.getItem(PREFIX + projectId);
602
+ } catch {
603
+ return null;
604
+ }
605
+ }
606
+ function writePreferredLanguage(projectId, langCode) {
607
+ try {
608
+ if (langCode === null) {
609
+ window.localStorage.removeItem(PREFIX + projectId);
610
+ } else {
611
+ window.localStorage.setItem(PREFIX + projectId, langCode);
612
+ }
613
+ } catch {
614
+ }
615
+ }
616
+
481
617
  // src/ui/flag-svgs.ts
482
618
  var FLAG_IMAGES = {
483
619
  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==",
@@ -1236,6 +1372,9 @@ var DabaLang = class {
1236
1372
  this.switcher = null;
1237
1373
  this.metadata = null;
1238
1374
  this.pathRouter = null;
1375
+ this.navWatcher = null;
1376
+ /** Tail of the re-translation chain; see retranslateCurrentPage. */
1377
+ this.retranslating = Promise.resolve();
1239
1378
  /** Currently active language (null = original). Tracked here rather
1240
1379
  * than via the applier because a site-translated page (see
1241
1380
  * isSiteTranslated) never runs the applier at all. */
@@ -1271,6 +1410,12 @@ var DabaLang = class {
1271
1410
  },
1272
1411
  this.options.view ?? "modal"
1273
1412
  );
1413
+ if (typeof window !== "undefined") {
1414
+ this.navWatcher = new NavigationWatcher(() => {
1415
+ void this.retranslateCurrentPage();
1416
+ });
1417
+ this.navWatcher.start();
1418
+ }
1274
1419
  if (this.metadata.enablePathRouting && typeof window !== "undefined") {
1275
1420
  this.pathRouter = new PathRouter(this.metadata.languages);
1276
1421
  const detected = this.pathRouter.detect(window.location.pathname);
@@ -1286,6 +1431,37 @@ var DabaLang = class {
1286
1431
  }
1287
1432
  }
1288
1433
  }
1434
+ if (this.activeLang === null && !this.pathRouter) {
1435
+ const remembered = readPreferredLanguage(this.options.projectId);
1436
+ if (remembered) {
1437
+ const stillOffered = (this.metadata?.targetLanguages ?? []).includes(remembered);
1438
+ if (!stillOffered) {
1439
+ writePreferredLanguage(this.options.projectId, null);
1440
+ } else if (this.readyLanguages().includes(remembered)) {
1441
+ await this.selectLanguage(remembered, { navigate: false });
1442
+ }
1443
+ }
1444
+ }
1445
+ }
1446
+ /**
1447
+ * Re-applies the active language to content that arrived after init.
1448
+ *
1449
+ * A no-op in the source language — there is nothing to apply — and on
1450
+ * a site-translated page, where the page already *is* the translation.
1451
+ */
1452
+ async retranslateCurrentPage() {
1453
+ if (!this.applier || this.activeLang === null) return;
1454
+ if (this.isSiteTranslated(this.activeLang)) return;
1455
+ this.retranslating = this.retranslating.catch(() => void 0).then(async () => {
1456
+ if (!this.applier || this.activeLang === null) return;
1457
+ this.applier.recrawl();
1458
+ try {
1459
+ await this.applier.applyLanguage(this.activeLang);
1460
+ } catch (err) {
1461
+ this.handleError(err);
1462
+ }
1463
+ });
1464
+ await this.retranslating;
1289
1465
  }
1290
1466
  /** True when the site serves this language's content itself (CMS,
1291
1467
  * localized build): a pre-translated ("developer") language. Combined
@@ -1333,6 +1509,7 @@ var DabaLang = class {
1333
1509
  }
1334
1510
  this.activeLang = langCode;
1335
1511
  this.switcher.setActive(langCode);
1512
+ writePreferredLanguage(this.options.projectId, langCode);
1336
1513
  this.options.onLanguageChange?.(langCode ?? this.metadata?.sourceLang ?? "");
1337
1514
  } catch (err) {
1338
1515
  this.handleError(err);
@@ -1355,10 +1532,27 @@ var DabaLang = class {
1355
1532
  whenReady() {
1356
1533
  return this.ready;
1357
1534
  }
1535
+ /**
1536
+ * Switch language programmatically — the same path the switcher takes,
1537
+ * so the choice is applied, persisted and reported identically.
1538
+ *
1539
+ * Exists because a host page often has its own reason to change
1540
+ * language: a footer link, a country picker, a preference synced from
1541
+ * an account. Without it those would have to fake a click on the
1542
+ * widget's own DOM.
1543
+ *
1544
+ * Pass null to return to the source language.
1545
+ */
1546
+ async setLanguage(langCode) {
1547
+ await this.ready;
1548
+ await this.selectLanguage(langCode);
1549
+ }
1358
1550
  currentLanguage() {
1359
1551
  return this.activeLang;
1360
1552
  }
1361
1553
  destroy() {
1554
+ this.navWatcher?.stop();
1555
+ this.navWatcher = null;
1362
1556
  this.editor?.detach();
1363
1557
  this.switcher?.destroy();
1364
1558
  this.docLang.restore();