@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.
@@ -51,6 +51,12 @@ interface LanguageConfig {
51
51
  pathPrefix: string | null;
52
52
  /** Language applied on un-prefixed paths when path routing is on. */
53
53
  isDefault: boolean;
54
+ /** False while the language is still being populated. The switcher
55
+ * omits it, so a visitor is never offered a language that would
56
+ * translate the page live in front of them. Absent (older gateways)
57
+ * counts as ready — a widget must not hide working languages because
58
+ * the server predates the field. */
59
+ ready: boolean;
54
60
  }
55
61
  interface ProjectMetadata {
56
62
  projectId: string;
@@ -82,6 +88,9 @@ declare class DabaLang {
82
88
  private switcher;
83
89
  private metadata;
84
90
  private pathRouter;
91
+ private navWatcher;
92
+ /** Tail of the re-translation chain; see retranslateCurrentPage. */
93
+ private retranslating;
85
94
  /** Currently active language (null = original). Tracked here rather
86
95
  * than via the applier because a site-translated page (see
87
96
  * isSiteTranslated) never runs the applier at all. */
@@ -89,11 +98,28 @@ declare class DabaLang {
89
98
  private readonly ready;
90
99
  constructor(options: DabaLangOptions);
91
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;
92
108
  /** True when the site serves this language's content itself (CMS,
93
109
  * localized build): a pre-translated ("developer") language. Combined
94
110
  * with a prefix match, the widget never machine-translates that page.
95
111
  * (A prefix-less developer language instead swaps its uploaded
96
112
  * catalogue in place.) */
113
+ /** Target languages that actually have translations behind them.
114
+ *
115
+ * A configured-but-unpopulated language is worse than a missing one: a
116
+ * visitor picks it and watches the page translate live, paragraph by
117
+ * paragraph, or lands on a half-translated page while a run is still
118
+ * going. Languages with no config row at all are legacy
119
+ * target_languages entries that predate per-language config — they are
120
+ * shown, because hiding a language that has been serving traffic would
121
+ * be a regression. */
122
+ private readyLanguages;
97
123
  private isSiteTranslated;
98
124
  private selectLanguage;
99
125
  /** Overridable seam for tests — jsdom can't perform real navigation. */
@@ -102,6 +128,18 @@ declare class DabaLang {
102
128
  /** Resolves once the initial metadata fetch + pill render has settled
103
129
  * (successfully or not) — mainly useful in tests. */
104
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>;
105
143
  currentLanguage(): string | null;
106
144
  destroy(): void;
107
145
  }
@@ -51,6 +51,12 @@ interface LanguageConfig {
51
51
  pathPrefix: string | null;
52
52
  /** Language applied on un-prefixed paths when path routing is on. */
53
53
  isDefault: boolean;
54
+ /** False while the language is still being populated. The switcher
55
+ * omits it, so a visitor is never offered a language that would
56
+ * translate the page live in front of them. Absent (older gateways)
57
+ * counts as ready — a widget must not hide working languages because
58
+ * the server predates the field. */
59
+ ready: boolean;
54
60
  }
55
61
  interface ProjectMetadata {
56
62
  projectId: string;
@@ -82,6 +88,9 @@ declare class DabaLang {
82
88
  private switcher;
83
89
  private metadata;
84
90
  private pathRouter;
91
+ private navWatcher;
92
+ /** Tail of the re-translation chain; see retranslateCurrentPage. */
93
+ private retranslating;
85
94
  /** Currently active language (null = original). Tracked here rather
86
95
  * than via the applier because a site-translated page (see
87
96
  * isSiteTranslated) never runs the applier at all. */
@@ -89,11 +98,28 @@ declare class DabaLang {
89
98
  private readonly ready;
90
99
  constructor(options: DabaLangOptions);
91
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;
92
108
  /** True when the site serves this language's content itself (CMS,
93
109
  * localized build): a pre-translated ("developer") language. Combined
94
110
  * with a prefix match, the widget never machine-translates that page.
95
111
  * (A prefix-less developer language instead swaps its uploaded
96
112
  * catalogue in place.) */
113
+ /** Target languages that actually have translations behind them.
114
+ *
115
+ * A configured-but-unpopulated language is worse than a missing one: a
116
+ * visitor picks it and watches the page translate live, paragraph by
117
+ * paragraph, or lands on a half-translated page while a run is still
118
+ * going. Languages with no config row at all are legacy
119
+ * target_languages entries that predate per-language config — they are
120
+ * shown, because hiding a language that has been serving traffic would
121
+ * be a regression. */
122
+ private readyLanguages;
97
123
  private isSiteTranslated;
98
124
  private selectLanguage;
99
125
  /** Overridable seam for tests — jsdom can't perform real navigation. */
@@ -102,6 +128,18 @@ declare class DabaLang {
102
128
  /** Resolves once the initial metadata fetch + pill render has settled
103
129
  * (successfully or not) — mainly useful in tests. */
104
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>;
105
143
  currentLanguage(): string | null;
106
144
  destroy(): void;
107
145
  }
package/dist/dabalang.js CHANGED
@@ -95,7 +95,10 @@ async function fetchProjectMetadata(gatewayUrl, projectId, apiKey) {
95
95
  code: l.code,
96
96
  provider: l.provider,
97
97
  pathPrefix: l.path_prefix,
98
- isDefault: l.is_default
98
+ isDefault: l.is_default,
99
+ // Absent means an older gateway that has no readiness concept —
100
+ // treat as ready rather than hiding every language.
101
+ ready: l.ready ?? true
99
102
  }))
100
103
  };
101
104
  }
@@ -204,13 +207,38 @@ var TranslationApplier = class {
204
207
  this.apiKey = apiKey;
205
208
  this.fetchedLanguages = /* @__PURE__ */ new Map();
206
209
  this.activeLang = null;
207
- 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);
208
236
  }
209
237
  /** Re-crawls the current DOM — call after content is known to have
210
238
  * changed (e.g. after a route change in an SPA). Not called
211
239
  * automatically; dabalang does one crawl per page load. */
212
240
  recrawl() {
213
- this.groups = groupByText(crawlVisibleText(this.root));
241
+ this.groups = this.absorb(crawlVisibleText(this.root));
214
242
  }
215
243
  currentLanguage() {
216
244
  return this.activeLang;
@@ -432,6 +460,97 @@ var InlineEditor = class {
432
460
  }
433
461
  };
434
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
+
435
554
  // src/path-routing.ts
436
555
  var PathRouter = class {
437
556
  constructor(languages) {
@@ -475,6 +594,26 @@ var PathRouter = class {
475
594
  }
476
595
  };
477
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
+
478
617
  // src/ui/flag-svgs.ts
479
618
  var FLAG_IMAGES = {
480
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==",
@@ -629,10 +768,14 @@ function injectStyles() {
629
768
  display: inline-flex;
630
769
  align-items: center;
631
770
  gap: 6px;
632
- border: 1px solid rgba(0, 0, 0, 0.12);
633
- background: #ffffff;
634
- color: rgba(0, 0, 0, 0.75);
635
- border-radius: 999px;
771
+ border: 1px solid var(--dabalang-border-color, rgba(0, 0, 0, 0.12));
772
+ background: var(--dabalang-bg, #ffffff);
773
+ color: var(--dabalang-color, rgba(0, 0, 0, 0.75));
774
+ /* Themeable so the switcher can match the host's own buttons. A widget
775
+ cannot know whether a site uses pills or 6px corners, and defaulting
776
+ to a pill made this the one control on the page that did not match.
777
+ Set --dabalang-radius on any ancestor to align it. */
778
+ border-radius: var(--dabalang-radius, 999px);
636
779
  padding: 6px 12px;
637
780
  cursor: pointer;
638
781
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
@@ -899,8 +1042,10 @@ function injectStyles() {
899
1042
  max-height: 320px;
900
1043
  overflow-y: auto;
901
1044
  background: #ffffff;
902
- border: 1px solid rgba(0, 0, 0, 0.1);
903
- border-radius: 14px;
1045
+ border: 1px solid var(--dabalang-border-color, rgba(0, 0, 0, 0.1));
1046
+ /* Panel corners follow the trigger's family, one step softer, so a
1047
+ square-cornered host does not get a pill-shaped dropdown. */
1048
+ border-radius: var(--dabalang-panel-radius, 14px);
904
1049
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14);
905
1050
  padding: 6px;
906
1051
  box-sizing: border-box;
@@ -1227,6 +1372,9 @@ var DabaLang = class {
1227
1372
  this.switcher = null;
1228
1373
  this.metadata = null;
1229
1374
  this.pathRouter = null;
1375
+ this.navWatcher = null;
1376
+ /** Tail of the re-translation chain; see retranslateCurrentPage. */
1377
+ this.retranslating = Promise.resolve();
1230
1378
  /** Currently active language (null = original). Tracked here rather
1231
1379
  * than via the applier because a site-translated page (see
1232
1380
  * isSiteTranslated) never runs the applier at all. */
@@ -1255,13 +1403,19 @@ var DabaLang = class {
1255
1403
  });
1256
1404
  this.switcher = renderLanguageSwitcher(
1257
1405
  this.container,
1258
- this.metadata.targetLanguages,
1406
+ this.readyLanguages(),
1259
1407
  this.metadata.sourceLang,
1260
1408
  (langCode) => {
1261
1409
  void this.selectLanguage(langCode);
1262
1410
  },
1263
1411
  this.options.view ?? "modal"
1264
1412
  );
1413
+ if (typeof window !== "undefined") {
1414
+ this.navWatcher = new NavigationWatcher(() => {
1415
+ void this.retranslateCurrentPage();
1416
+ });
1417
+ this.navWatcher.start();
1418
+ }
1265
1419
  if (this.metadata.enablePathRouting && typeof window !== "undefined") {
1266
1420
  this.pathRouter = new PathRouter(this.metadata.languages);
1267
1421
  const detected = this.pathRouter.detect(window.location.pathname);
@@ -1277,12 +1431,59 @@ var DabaLang = class {
1277
1431
  }
1278
1432
  }
1279
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;
1280
1465
  }
1281
1466
  /** True when the site serves this language's content itself (CMS,
1282
1467
  * localized build): a pre-translated ("developer") language. Combined
1283
1468
  * with a prefix match, the widget never machine-translates that page.
1284
1469
  * (A prefix-less developer language instead swaps its uploaded
1285
1470
  * catalogue in place.) */
1471
+ /** Target languages that actually have translations behind them.
1472
+ *
1473
+ * A configured-but-unpopulated language is worse than a missing one: a
1474
+ * visitor picks it and watches the page translate live, paragraph by
1475
+ * paragraph, or lands on a half-translated page while a run is still
1476
+ * going. Languages with no config row at all are legacy
1477
+ * target_languages entries that predate per-language config — they are
1478
+ * shown, because hiding a language that has been serving traffic would
1479
+ * be a regression. */
1480
+ readyLanguages() {
1481
+ const configs = this.metadata?.languages ?? [];
1482
+ return (this.metadata?.targetLanguages ?? []).filter((code) => {
1483
+ const config = configs.find((l) => l.code === code);
1484
+ return config ? config.ready : true;
1485
+ });
1486
+ }
1286
1487
  isSiteTranslated(langCode) {
1287
1488
  const config = this.metadata?.languages.find((l) => l.code === langCode);
1288
1489
  return config?.provider === "developer";
@@ -1308,6 +1509,7 @@ var DabaLang = class {
1308
1509
  }
1309
1510
  this.activeLang = langCode;
1310
1511
  this.switcher.setActive(langCode);
1512
+ writePreferredLanguage(this.options.projectId, langCode);
1311
1513
  this.options.onLanguageChange?.(langCode ?? this.metadata?.sourceLang ?? "");
1312
1514
  } catch (err) {
1313
1515
  this.handleError(err);
@@ -1330,10 +1532,27 @@ var DabaLang = class {
1330
1532
  whenReady() {
1331
1533
  return this.ready;
1332
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
+ }
1333
1550
  currentLanguage() {
1334
1551
  return this.activeLang;
1335
1552
  }
1336
1553
  destroy() {
1554
+ this.navWatcher?.stop();
1555
+ this.navWatcher = null;
1337
1556
  this.editor?.detach();
1338
1557
  this.switcher?.destroy();
1339
1558
  this.docLang.restore();