@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 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
- 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);
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 = groupByText(crawlVisibleText(this.root));
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
- border-radius: 999px;
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
- border-radius: 14px;
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.metadata.targetLanguages,
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();