@lionrapid/core 0.3.0 → 0.4.0

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.
Files changed (89) hide show
  1. package/dist/browser/attribute-extractor.d.ts +54 -0
  2. package/dist/browser/attribute-extractor.d.ts.map +1 -0
  3. package/dist/browser/attribute-extractor.js +155 -0
  4. package/dist/browser/attribute-extractor.js.map +1 -0
  5. package/dist/browser/auto-init.d.ts +4 -0
  6. package/dist/browser/auto-init.d.ts.map +1 -1
  7. package/dist/browser/auto-init.js +6 -2
  8. package/dist/browser/auto-init.js.map +1 -1
  9. package/dist/browser/auto-translator.d.ts +23 -0
  10. package/dist/browser/auto-translator.d.ts.map +1 -1
  11. package/dist/browser/auto-translator.js +90 -5
  12. package/dist/browser/auto-translator.js.map +1 -1
  13. package/dist/browser/block-extractor.d.ts +8 -0
  14. package/dist/browser/block-extractor.d.ts.map +1 -1
  15. package/dist/browser/block-extractor.js +77 -0
  16. package/dist/browser/block-extractor.js.map +1 -1
  17. package/dist/browser/editor/editor-api.d.ts +22 -1
  18. package/dist/browser/editor/editor-api.d.ts.map +1 -1
  19. package/dist/browser/editor/editor-api.js +54 -19
  20. package/dist/browser/editor/editor-api.js.map +1 -1
  21. package/dist/browser/editor/editor-init.js +72 -3
  22. package/dist/browser/editor/editor-init.js.map +1 -1
  23. package/dist/browser/editor/editor-styles.d.ts +1 -1
  24. package/dist/browser/editor/editor-styles.d.ts.map +1 -1
  25. package/dist/browser/editor/editor-styles.js +38 -1
  26. package/dist/browser/editor/editor-styles.js.map +1 -1
  27. package/dist/browser/editor/inspector.d.ts +8 -0
  28. package/dist/browser/editor/inspector.d.ts.map +1 -1
  29. package/dist/browser/editor/inspector.js +14 -0
  30. package/dist/browser/editor/inspector.js.map +1 -1
  31. package/dist/browser/editor/live-preview.d.ts +1 -1
  32. package/dist/browser/editor/live-preview.d.ts.map +1 -1
  33. package/dist/browser/editor/live-preview.js +27 -16
  34. package/dist/browser/editor/live-preview.js.map +1 -1
  35. package/dist/browser/editor/locale-url.d.ts +18 -0
  36. package/dist/browser/editor/locale-url.d.ts.map +1 -0
  37. package/dist/browser/editor/locale-url.js +37 -0
  38. package/dist/browser/editor/locale-url.js.map +1 -0
  39. package/dist/browser/editor/sidebar.d.ts +6 -0
  40. package/dist/browser/editor/sidebar.d.ts.map +1 -1
  41. package/dist/browser/editor/sidebar.js +133 -4
  42. package/dist/browser/editor/sidebar.js.map +1 -1
  43. package/dist/browser/editor/toolbar.d.ts +8 -1
  44. package/dist/browser/editor/toolbar.d.ts.map +1 -1
  45. package/dist/browser/editor/toolbar.js +49 -4
  46. package/dist/browser/editor/toolbar.js.map +1 -1
  47. package/dist/browser/index.d.ts +2 -0
  48. package/dist/browser/index.d.ts.map +1 -1
  49. package/dist/browser/index.js +1 -0
  50. package/dist/browser/index.js.map +1 -1
  51. package/dist/browser/lionrapid.auto.dev.js +324 -36
  52. package/dist/browser/lionrapid.auto.dev.js.map +4 -4
  53. package/dist/browser/lionrapid.auto.js +324 -36
  54. package/dist/browser/lionrapid.auto.js.map +4 -4
  55. package/dist/browser/lionrapid.auto.min.js +3 -3
  56. package/dist/browser/lionrapid.auto.min.js.map +4 -4
  57. package/dist/browser/lionrapid.editor.js +371 -36
  58. package/dist/browser/lionrapid.editor.js.map +3 -3
  59. package/dist/browser/lionrapid.editor.min.js +41 -4
  60. package/dist/browser/lionrapid.editor.min.js.map +4 -4
  61. package/dist/browser/lionrapid.esm.js +40 -26
  62. package/dist/browser/lionrapid.esm.js.map +2 -2
  63. package/dist/browser/lionrapid.esm.min.js +2 -2
  64. package/dist/browser/lionrapid.esm.min.js.map +3 -3
  65. package/dist/browser/lionrapid.iife.dev.js +40 -26
  66. package/dist/browser/lionrapid.iife.dev.js.map +2 -2
  67. package/dist/browser/lionrapid.iife.js +40 -26
  68. package/dist/browser/lionrapid.iife.js.map +2 -2
  69. package/dist/browser/lionrapid.iife.min.js +2 -2
  70. package/dist/browser/lionrapid.iife.min.js.map +3 -3
  71. package/dist/browser/lionrapid.umd.js +40 -26
  72. package/dist/browser/lionrapid.umd.js.map +2 -2
  73. package/dist/browser/lionrapid.umd.min.js +2 -2
  74. package/dist/browser/lionrapid.umd.min.js.map +3 -3
  75. package/dist/browser/md5.d.ts +3 -2
  76. package/dist/browser/md5.d.ts.map +1 -1
  77. package/dist/browser/md5.js +41 -6
  78. package/dist/browser/md5.js.map +1 -1
  79. package/dist/content/parser/MarkupParser.d.ts +0 -10
  80. package/dist/content/parser/MarkupParser.d.ts.map +1 -1
  81. package/dist/content/parser/MarkupParser.js +45 -3
  82. package/dist/content/parser/MarkupParser.js.map +1 -1
  83. package/dist/content/types.d.ts +30 -0
  84. package/dist/content/types.d.ts.map +1 -1
  85. package/dist/repositories/network-repository.d.ts +0 -14
  86. package/dist/repositories/network-repository.d.ts.map +1 -1
  87. package/dist/repositories/network-repository.js +4 -26
  88. package/dist/repositories/network-repository.js.map +1 -1
  89. package/package.json +1 -1
@@ -64,7 +64,7 @@ var LionRapidEditor = (() => {
64
64
  color: #fff;
65
65
  padding: 8px 12px;
66
66
  border-radius: 8px;
67
- margin-right: 8px;
67
+ margin-right: 8px; /* gap between panel (left) and the FAB button (right) */
68
68
  box-shadow: 0 4px 12px rgba(0,0,0,0.25);
69
69
  white-space: nowrap;
70
70
  }
@@ -174,6 +174,43 @@ var LionRapidEditor = (() => {
174
174
  cursor: not-allowed;
175
175
  }
176
176
 
177
+ .lr-fab-browse {
178
+ background: none;
179
+ border: 1px solid rgba(255,255,255,0.3);
180
+ color: #fff;
181
+ padding: 4px 10px;
182
+ border-radius: 4px;
183
+ cursor: pointer;
184
+ font-size: 13px;
185
+ }
186
+
187
+ .lr-fab-browse:hover {
188
+ background: rgba(255,255,255,0.1);
189
+ }
190
+
191
+ .lr-fab-browse.active {
192
+ background: #f5a623;
193
+ border-color: #f5a623;
194
+ color: #1a1a2e;
195
+ font-weight: 600;
196
+ }
197
+
198
+ .lr-link-nav {
199
+ background: none;
200
+ border: 1px solid #2d7f7f;
201
+ color: #2d7f7f;
202
+ padding: 6px 12px;
203
+ border-radius: 4px;
204
+ cursor: pointer;
205
+ font-size: 13px;
206
+ font-weight: 600;
207
+ }
208
+
209
+ .lr-link-nav:hover {
210
+ background: #2d7f7f;
211
+ color: #fff;
212
+ }
213
+
177
214
  /* Workflow action buttons (Phase 3) */
178
215
  .lr-workflow-actions {
179
216
  display: flex;
@@ -748,15 +785,46 @@ var LionRapidEditor = (() => {
748
785
  }
749
786
  return str;
750
787
  }
751
- function str2binl(str) {
788
+ function utf8Bytes(str) {
789
+ if (typeof TextEncoder !== "undefined") {
790
+ return new TextEncoder().encode(str);
791
+ }
792
+ const out = [];
793
+ for (let i = 0; i < str.length; i++) {
794
+ const c = str.charCodeAt(i);
795
+ if (c < 128) {
796
+ out.push(c);
797
+ } else if (c < 2048) {
798
+ out.push(192 | c >> 6, 128 | c & 63);
799
+ } else if (c >= 55296 && c < 56320 && i + 1 < str.length) {
800
+ const c2 = str.charCodeAt(++i);
801
+ const cp = 65536 + (c - 55296 << 10) + (c2 - 56320);
802
+ out.push(
803
+ 240 | cp >> 18,
804
+ 128 | cp >> 12 & 63,
805
+ 128 | cp >> 6 & 63,
806
+ 128 | cp & 63
807
+ );
808
+ } else {
809
+ out.push(
810
+ 224 | c >> 12,
811
+ 128 | c >> 6 & 63,
812
+ 128 | c & 63
813
+ );
814
+ }
815
+ }
816
+ return new Uint8Array(out);
817
+ }
818
+ function bytes2binl(bytes) {
752
819
  const bin = [];
753
- for (let i = 0; i < str.length * 8; i += 8) {
754
- bin[i >> 5] |= (str.charCodeAt(i / 8) & 255) << i % 32;
820
+ for (let i = 0; i < bytes.length; i++) {
821
+ bin[i >> 2] |= bytes[i] << i % 4 * 8;
755
822
  }
756
823
  return bin;
757
824
  }
758
825
  function md5(str) {
759
- return binl2hex(binlMD5(str2binl(str), str.length * 8));
826
+ const bytes = utf8Bytes(str);
827
+ return binl2hex(binlMD5(bytes2binl(bytes), bytes.length * 8));
760
828
  }
761
829
  function generateAutoKey(text) {
762
830
  const trimmed = text.trim();
@@ -838,6 +906,19 @@ var LionRapidEditor = (() => {
838
906
  this.currentTarget = null;
839
907
  this.overlay.classList.remove("active");
840
908
  }
909
+ /**
910
+ * Suspend/resume the inspector. When inactive, hover/click handlers
911
+ * early-return so page links navigate natively — this is what the toolbar's
912
+ * "Browse" mode flips so a PM can click through the site's nav to reach
913
+ * another page, instead of every click opening the edit drawer.
914
+ */
915
+ setActive(active) {
916
+ this.active = active;
917
+ if (!active) this.clearHighlight();
918
+ }
919
+ isActive() {
920
+ return this.active;
921
+ }
841
922
  handleMouseOver(e) {
842
923
  if (!this.active) return;
843
924
  const target = e.target;
@@ -917,7 +998,7 @@ var LionRapidEditor = (() => {
917
998
 
918
999
  // src/browser/editor/toolbar.ts
919
1000
  var Toolbar = class {
920
- constructor(shadowRoot, languages, currentLocale, onLanguageChange, onExit, onCaptureSnapshot = null, captureOptions = {}) {
1001
+ constructor(shadowRoot, languages, currentLocale, onLanguageChange, onExit, onCaptureSnapshot = null, captureOptions = {}, onBrowseToggle = null) {
921
1002
  __publicField(this, "el");
922
1003
  __publicField(this, "langBtnEl");
923
1004
  __publicField(this, "langMenuEl");
@@ -927,6 +1008,9 @@ var LionRapidEditor = (() => {
927
1008
  __publicField(this, "onLanguageChange");
928
1009
  __publicField(this, "onExit");
929
1010
  __publicField(this, "onCaptureSnapshot");
1011
+ __publicField(this, "onBrowseToggle");
1012
+ __publicField(this, "browsing", false);
1013
+ __publicField(this, "browseBtnEl", null);
930
1014
  // Drag state
931
1015
  __publicField(this, "dragging", false);
932
1016
  __publicField(this, "dragOffsetX", 0);
@@ -934,6 +1018,7 @@ var LionRapidEditor = (() => {
934
1018
  this.onLanguageChange = onLanguageChange;
935
1019
  this.onExit = onExit;
936
1020
  this.onCaptureSnapshot = onCaptureSnapshot;
1021
+ this.onBrowseToggle = onBrowseToggle;
937
1022
  this.currentLocale = currentLocale;
938
1023
  const captureMode = captureOptions.mode ?? "capture";
939
1024
  this.el = document.createElement("div");
@@ -946,7 +1031,6 @@ var LionRapidEditor = (() => {
946
1031
  e.stopPropagation();
947
1032
  this.toggle();
948
1033
  });
949
- this.el.appendChild(fabBtn);
950
1034
  const panel = document.createElement("div");
951
1035
  panel.className = "lr-fab-panel";
952
1036
  const langWrap = document.createElement("div");
@@ -995,6 +1079,18 @@ var LionRapidEditor = (() => {
995
1079
  });
996
1080
  panel.appendChild(captureBtn);
997
1081
  }
1082
+ if (this.onBrowseToggle) {
1083
+ const browseBtn = document.createElement("button");
1084
+ browseBtn.className = "lr-fab-browse";
1085
+ browseBtn.textContent = "Browse";
1086
+ browseBtn.title = "Pause editing so you can click links and navigate to another page";
1087
+ browseBtn.addEventListener("click", (e) => {
1088
+ e.stopPropagation();
1089
+ this.setBrowsing(!this.browsing);
1090
+ });
1091
+ this.browseBtnEl = browseBtn;
1092
+ panel.appendChild(browseBtn);
1093
+ }
998
1094
  this.progressEl = document.createElement("span");
999
1095
  this.progressEl.className = "lr-fab-progress";
1000
1096
  panel.appendChild(this.progressEl);
@@ -1004,6 +1100,7 @@ var LionRapidEditor = (() => {
1004
1100
  exitBtn.addEventListener("click", () => this.onExit());
1005
1101
  panel.appendChild(exitBtn);
1006
1102
  this.el.appendChild(panel);
1103
+ this.el.appendChild(fabBtn);
1007
1104
  shadowRoot.appendChild(this.el);
1008
1105
  this.handleMouseDown = this.handleMouseDown.bind(this);
1009
1106
  this.handleMouseMove = this.handleMouseMove.bind(this);
@@ -1014,6 +1111,16 @@ var LionRapidEditor = (() => {
1014
1111
  this.expanded = !this.expanded;
1015
1112
  this.el.classList.toggle("expanded", this.expanded);
1016
1113
  }
1114
+ setBrowsing(browsing) {
1115
+ this.browsing = browsing;
1116
+ if (this.browseBtnEl) {
1117
+ this.browseBtnEl.textContent = browsing ? "Editing" : "Browse";
1118
+ this.browseBtnEl.classList.toggle("active", browsing);
1119
+ this.browseBtnEl.title = browsing ? "Resume editing \u2014 clicks open the edit drawer again" : "Pause editing so you can click links and navigate to another page";
1120
+ }
1121
+ this.el.classList.toggle("lr-fab--browsing", browsing);
1122
+ if (this.onBrowseToggle) this.onBrowseToggle(browsing);
1123
+ }
1017
1124
  updateProgress(translated, total) {
1018
1125
  this.progressEl.textContent = `${translated}/${total}`;
1019
1126
  }
@@ -1036,9 +1143,10 @@ var LionRapidEditor = (() => {
1036
1143
  this.dragging = true;
1037
1144
  const x = Math.max(0, Math.min(e.clientX - this.dragOffsetX, window.innerWidth - this.el.offsetWidth));
1038
1145
  const y = Math.max(0, Math.min(e.clientY - this.dragOffsetY, window.innerHeight - this.el.offsetHeight));
1039
- this.el.style.left = `${x}px`;
1146
+ const rightOffset = window.innerWidth - x - this.el.offsetWidth;
1147
+ this.el.style.right = `${Math.max(0, rightOffset)}px`;
1040
1148
  this.el.style.top = `${y}px`;
1041
- this.el.style.right = "auto";
1149
+ this.el.style.left = "auto";
1042
1150
  this.el.style.bottom = "auto";
1043
1151
  }
1044
1152
  handleMouseUp() {
@@ -1085,7 +1193,7 @@ var LionRapidEditor = (() => {
1085
1193
 
1086
1194
  // src/browser/editor/live-preview.ts
1087
1195
  var htmlSerializer = new HTMLSerializer();
1088
- function applyLivePreview(element, text, styles, key, locale, namespace) {
1196
+ function applyLivePreview(element, text, styles, key, locale, namespace, sourceText, rowState) {
1089
1197
  if (!element.hasAttribute("data-lr-original")) {
1090
1198
  element.setAttribute("data-lr-original", element.innerHTML);
1091
1199
  }
@@ -1096,11 +1204,17 @@ var LionRapidEditor = (() => {
1096
1204
  cache.set(fullKey, text, styles);
1097
1205
  }
1098
1206
  const hasIndexedMarkers = /<\/?[0-9]+\/?>/.test(text);
1099
- const sourceHasStyles = styles && Object.keys(styles).length > 0;
1207
+ const isMachineTranslated = rowState === "machine_translated";
1208
+ const fullyWrapped = sourceText ? sourceText.match(/^<(\d+)>([\s\S]*)<\/\1>$/) : null;
1100
1209
  if (hasIndexedMarkers) {
1101
1210
  const unit = { text, styles };
1102
1211
  element.innerHTML = htmlSerializer.serialize(unit);
1103
- } else if (!sourceHasStyles) {
1212
+ } else if (isMachineTranslated && fullyWrapped && styles && Object.keys(styles).length > 0) {
1213
+ const idx = fullyWrapped[1];
1214
+ const wrappedText = `<${idx}>${text}</${idx}>`;
1215
+ const unit = { text: wrappedText, styles };
1216
+ element.innerHTML = htmlSerializer.serialize(unit);
1217
+ } else {
1104
1218
  element.textContent = text;
1105
1219
  }
1106
1220
  element.setAttribute("data-lr-block", key);
@@ -1168,6 +1282,7 @@ var LionRapidEditor = (() => {
1168
1282
 
1169
1283
  // src/browser/block-extractor.ts
1170
1284
  var BLOCK_SELECTOR3 = "p,h1,h2,h3,h4,h5,h6,li,td,th,label,button,figcaption,dt,dd,summary,caption";
1285
+ var INLINE_BLOCK_SELECTOR = "a";
1171
1286
  var SKIP_TAGS2 = /* @__PURE__ */ new Set([
1172
1287
  "SCRIPT",
1173
1288
  "STYLE",
@@ -1256,8 +1371,60 @@ var LionRapidEditor = (() => {
1256
1371
  }
1257
1372
  results.push({ element: el, unit, key });
1258
1373
  }
1374
+ this.extractOrphanInline(root, results, includeTranslated);
1259
1375
  return results;
1260
1376
  }
1377
+ /**
1378
+ * Second pass: extract inline elements (currently <a>) that have no block
1379
+ * ancestor. These would otherwise slip through both the block pass (not in
1380
+ * BLOCK_SELECTOR) and the inline-style capture (no parent block to host
1381
+ * them as `<1>...</1>`). Common shape: a `<div>` action group containing
1382
+ * only links.
1383
+ */
1384
+ extractOrphanInline(root, results, includeTranslated) {
1385
+ const candidates = root.querySelectorAll(INLINE_BLOCK_SELECTOR);
1386
+ const elements = [];
1387
+ if (root instanceof Element && root.matches(INLINE_BLOCK_SELECTOR)) {
1388
+ elements.push(root);
1389
+ }
1390
+ for (let i = 0; i < candidates.length; i++) {
1391
+ elements.push(candidates[i]);
1392
+ }
1393
+ for (let i = 0; i < elements.length; i++) {
1394
+ const el = elements[i];
1395
+ if (this.isInsideSkipTag(el)) continue;
1396
+ if (!includeTranslated && this.hasSkipAttribute(el)) continue;
1397
+ if (this.matchesExcludeSelector(el)) continue;
1398
+ if (el.getAttribute("translate") === "no") continue;
1399
+ if (el.parentElement?.closest(BLOCK_SELECTOR3)) continue;
1400
+ if (el.parentElement?.closest(INLINE_BLOCK_SELECTOR)) continue;
1401
+ if (el.querySelector(BLOCK_SELECTOR3)) continue;
1402
+ const lrOriginal = el.getAttribute("data-lr-original");
1403
+ let unit;
1404
+ if (lrOriginal) {
1405
+ const temp = document.createElement(el.tagName);
1406
+ temp.innerHTML = lrOriginal;
1407
+ unit = parseBlockElement(temp);
1408
+ } else {
1409
+ unit = parseBlockElement(el);
1410
+ }
1411
+ const strippedText = unit.text.replace(/<\/?[0-9]+\/?>/g, "");
1412
+ if (this.excludeFilter.shouldExclude(strippedText)) continue;
1413
+ const lrBlock = el.getAttribute("data-lr-block");
1414
+ let key;
1415
+ if (lrBlock) {
1416
+ key = lrBlock;
1417
+ } else {
1418
+ const dataI18n = el.getAttribute("data-i18n");
1419
+ if (dataI18n) {
1420
+ key = dataI18n.includes(":") ? dataI18n : `common:${dataI18n}`;
1421
+ } else {
1422
+ key = generateAutoKey(unit.text);
1423
+ }
1424
+ }
1425
+ results.push({ element: el, unit, key });
1426
+ }
1427
+ }
1261
1428
  isInsideSkipTag(element) {
1262
1429
  let current = element.parentElement;
1263
1430
  while (current) {
@@ -1293,6 +1460,23 @@ var LionRapidEditor = (() => {
1293
1460
  }
1294
1461
  };
1295
1462
 
1463
+ // src/browser/editor/locale-url.ts
1464
+ function localizeUrl(href, defaultLocale) {
1465
+ let url;
1466
+ try {
1467
+ url = new URL(href, window.location.href);
1468
+ } catch {
1469
+ return href;
1470
+ }
1471
+ if (url.origin !== window.location.origin) return href;
1472
+ const currentMatch = window.location.pathname.match(/^\/([a-z]{2})(\/|$)/);
1473
+ const currentLocale = currentMatch ? currentMatch[1] : defaultLocale;
1474
+ if (currentLocale === defaultLocale) return url.href;
1475
+ if (/^\/[a-z]{2}(\/|$)/.test(url.pathname)) return url.href;
1476
+ url.pathname = "/" + currentLocale + url.pathname;
1477
+ return url.href;
1478
+ }
1479
+
1296
1480
  // src/browser/editor/sidebar.ts
1297
1481
  var htmlSerializer2 = new HTMLSerializer();
1298
1482
  var Sidebar = class {
@@ -1361,6 +1545,39 @@ var LionRapidEditor = (() => {
1361
1545
  this.refreshBlocksList();
1362
1546
  this.emitProgress();
1363
1547
  });
1548
+ if (typeof window !== "undefined") {
1549
+ window.__lr_dump_keys = () => {
1550
+ const blocks = this.blockExtractor.extract(document.body, true);
1551
+ const rows = blocks.map((b, i) => {
1552
+ const s = this.statusMap.get(b.key);
1553
+ return {
1554
+ i,
1555
+ key: b.key,
1556
+ text: b.unit.text,
1557
+ stylesJson: JSON.stringify(b.unit.styles),
1558
+ statusText: s?.text ?? "",
1559
+ isReady: !!s?.isReady,
1560
+ hasTranslation: !!s?.text
1561
+ };
1562
+ });
1563
+ console.table(rows);
1564
+ return rows;
1565
+ };
1566
+ window.__lr_editor_edit = (key, text) => {
1567
+ const blocks = this.blockExtractor.extract(document.body, true);
1568
+ const block = blocks.find((b) => b.key === key);
1569
+ if (!block) throw new Error(`[__lr_editor_edit] no block on page with key ${key}`);
1570
+ this.unsavedKeys.add(key);
1571
+ this.api.queueSave({
1572
+ key,
1573
+ locale: this.locale,
1574
+ namespace: this.namespace,
1575
+ text,
1576
+ styles: block.unit.styles
1577
+ });
1578
+ };
1579
+ window.__lr_editor_flush = () => this.api.flush();
1580
+ }
1364
1581
  shadowRoot.appendChild(this.el);
1365
1582
  }
1366
1583
  open(resolved) {
@@ -1395,12 +1612,33 @@ var LionRapidEditor = (() => {
1395
1612
  for (const [key, status] of Object.entries(statuses)) {
1396
1613
  this.statusMap.set(key, status);
1397
1614
  }
1615
+ const divergent = [];
1616
+ let untranslatedCount = 0;
1398
1617
  for (const block of blocks) {
1399
1618
  const status = this.statusMap.get(block.key);
1400
1619
  if (status && status.text && !status.isReady) {
1401
- applyLivePreview(block.element, status.text, block.unit.styles, block.key, this.locale, this.namespace);
1620
+ applyLivePreview(block.element, status.text, block.unit.styles, block.key, this.locale, this.namespace, block.unit.text, status.rowState);
1621
+ } else if (!status || !status.text) {
1622
+ untranslatedCount++;
1623
+ const stamp = block.element.getAttribute("data-lr-block");
1624
+ if (stamp && stamp.startsWith("auto_")) {
1625
+ const recomputed = generateAutoKey(block.unit.text);
1626
+ if (recomputed !== stamp) {
1627
+ divergent.push({ stamped: stamp, recomputed, text: block.unit.text.slice(0, 80) });
1628
+ }
1629
+ }
1402
1630
  }
1403
1631
  }
1632
+ if (divergent.length > 0) {
1633
+ console.warn(
1634
+ `[LionRapid Editor] ${divergent.length} block(s) where the client-side auto_ key does NOT match the server's data-lr-block stamp \u2014 JS and server key generation are diverging for these inputs. Sample (first 5):`,
1635
+ divergent.slice(0, 5)
1636
+ );
1637
+ } else if (untranslatedCount > 0) {
1638
+ console.debug(
1639
+ `[LionRapid Editor] ${untranslatedCount} block(s) untranslated (no server text yet) \u2014 normal for in-progress pages.`
1640
+ );
1641
+ }
1404
1642
  this.emitProgress();
1405
1643
  }
1406
1644
  getTranslatedCount() {
@@ -1453,6 +1691,7 @@ var LionRapidEditor = (() => {
1453
1691
  origEl.innerHTML = this.renderTextWithTags(unit.text);
1454
1692
  return origEl;
1455
1693
  });
1694
+ this.renderLinkNav(element);
1456
1695
  const isSourceLocale = this.locale === this.defaultLocale;
1457
1696
  const isLocked = isSourceLocale || status?.isLocked || status?.isReady || false;
1458
1697
  const textarea = document.createElement("textarea");
@@ -1484,7 +1723,7 @@ var LionRapidEditor = (() => {
1484
1723
  const text = textarea.value;
1485
1724
  previewEl.innerHTML = this.renderPreview(text, unit.styles);
1486
1725
  if (text) {
1487
- applyLivePreview(element, text, unit.styles, key, this.locale, this.namespace);
1726
+ applyLivePreview(element, text, unit.styles, key, this.locale, this.namespace, unit.text, "edited");
1488
1727
  }
1489
1728
  this.unsavedKeys.add(key);
1490
1729
  this.api.queueSave({
@@ -1652,6 +1891,33 @@ var LionRapidEditor = (() => {
1652
1891
  }
1653
1892
  this.emitProgress();
1654
1893
  }
1894
+ /**
1895
+ * Render an "Open linked page →" button when the selected block is or
1896
+ * contains a same-origin link. Cross-origin links are skipped — the editor
1897
+ * session only applies to this site. No-op when there's no usable link.
1898
+ */
1899
+ renderLinkNav(element) {
1900
+ const anchor = element.matches?.("a[href]") ? element : element.querySelector?.("a[href]");
1901
+ if (!anchor) return;
1902
+ let dest;
1903
+ try {
1904
+ dest = new URL(anchor.href, window.location.href);
1905
+ } catch {
1906
+ return;
1907
+ }
1908
+ if (dest.origin !== window.location.origin) return;
1909
+ if (dest.pathname === window.location.pathname && !dest.hash) return;
1910
+ this.addField("Linked page", () => {
1911
+ const btn = document.createElement("button");
1912
+ btn.className = "lr-link-nav";
1913
+ btn.textContent = "Open linked page \u2192";
1914
+ btn.title = dest.pathname + dest.search;
1915
+ btn.addEventListener("click", () => {
1916
+ window.location.href = localizeUrl(dest.href, this.defaultLocale);
1917
+ });
1918
+ return btn;
1919
+ });
1920
+ }
1655
1921
  addField(label, contentFn) {
1656
1922
  const field = document.createElement("div");
1657
1923
  field.className = "lr-field";
@@ -1788,6 +2054,8 @@ var LionRapidEditor = (() => {
1788
2054
  __publicField(this, "handleVisibility", () => {
1789
2055
  if (document.visibilityState === "hidden") {
1790
2056
  this.flush();
2057
+ } else if (document.visibilityState === "visible") {
2058
+ this.maybeRenewSession();
1791
2059
  }
1792
2060
  });
1793
2061
  this.apiBase = apiBase.replace(/\/$/, "");
@@ -1851,14 +2119,11 @@ var LionRapidEditor = (() => {
1851
2119
  const items = [...this.queue];
1852
2120
  this.onStatus("saving");
1853
2121
  try {
1854
- const response = await fetch(
2122
+ const response = await this.authFetch(
1855
2123
  `${this.apiBase}/api/integrations/targets/batch`,
1856
2124
  {
1857
2125
  method: "PUT",
1858
- headers: {
1859
- "Content-Type": "application/json",
1860
- "Authorization": `Bearer ${this.sessionToken}`
1861
- },
2126
+ headers: { "Content-Type": "application/json" },
1862
2127
  body: JSON.stringify({
1863
2128
  translations: items.map((t) => ({
1864
2129
  key: `${t.locale}:${t.namespace}:${t.key}`,
@@ -1900,12 +2165,9 @@ var LionRapidEditor = (() => {
1900
2165
  const batch = keys.slice(0, 200);
1901
2166
  const params = new URLSearchParams({ locale, namespace, keys: batch.join(",") });
1902
2167
  try {
1903
- const response = await fetch(
2168
+ const response = await this.authFetch(
1904
2169
  `${this.apiBase}/api/integrations/targets/status?${params}`,
1905
- {
1906
- headers: { "Authorization": `Bearer ${this.sessionToken}` },
1907
- signal: this.statusAbort.signal
1908
- }
2170
+ { signal: this.statusAbort.signal }
1909
2171
  );
1910
2172
  if (response.status === 401) {
1911
2173
  this.handleSessionExpired();
@@ -1932,9 +2194,7 @@ var LionRapidEditor = (() => {
1932
2194
  if (this.workflowInfoPromise) return this.workflowInfoPromise;
1933
2195
  this.workflowInfoPromise = (async () => {
1934
2196
  try {
1935
- const response = await fetch(`${this.apiBase}/api/integrations/editor/workflow`, {
1936
- headers: { "Authorization": `Bearer ${this.sessionToken}` }
1937
- });
2197
+ const response = await this.authFetch(`${this.apiBase}/api/integrations/editor/workflow`);
1938
2198
  if (response.status === 401) {
1939
2199
  this.handleSessionExpired();
1940
2200
  return null;
@@ -1957,15 +2217,30 @@ var LionRapidEditor = (() => {
1957
2217
  // Session renewal
1958
2218
  // ============================================
1959
2219
  /**
1960
- * Check if session is within 2min of expiry, and renew if so.
2220
+ * Check if session is within 5min of expiry, and renew if so.
2221
+ *
2222
+ * The threshold is intentionally generous because background tabs throttle
2223
+ * setInterval to once-per-minute (Chrome) or even less aggressively, and
2224
+ * a sleeping laptop pauses timers entirely. Renewing early gives us
2225
+ * headroom for those cases — the renewal interval still runs before the
2226
+ * 15min server-side TTL elapses even if it fires a minute or two late.
1961
2227
  */
1962
2228
  async maybeRenewSession() {
1963
2229
  const exp = this.getSessionExp();
1964
2230
  if (!exp) return;
1965
2231
  const timeLeft = exp * 1e3 - Date.now();
1966
- if (timeLeft > 12e4) return;
2232
+ if (timeLeft > 3e5) return;
1967
2233
  await this.renewSession();
1968
2234
  }
2235
+ /**
2236
+ * Attempt one renewal and signal whether the token actually changed.
2237
+ * Used by the 401-retry path in API calls.
2238
+ */
2239
+ async tryRenew() {
2240
+ const before = this.sessionToken;
2241
+ await this.renewSession();
2242
+ return this.sessionToken !== before;
2243
+ }
1969
2244
  async renewSession() {
1970
2245
  try {
1971
2246
  const response = await fetch(`${this.apiBase}/api/editor/renew`, {
@@ -2008,6 +2283,30 @@ var LionRapidEditor = (() => {
2008
2283
  return null;
2009
2284
  }
2010
2285
  }
2286
+ /**
2287
+ * Authenticated fetch with one-shot 401 retry. If the request comes back
2288
+ * 401, we attempt a session renewal and replay the request once with the
2289
+ * new token. If renewal fails (token past the server's 2min grace), the
2290
+ * caller still gets a 401 and falls through to handleSessionExpired.
2291
+ *
2292
+ * Always passes `init.signal` straight through so abortable callers
2293
+ * (e.g. fetchStatus) still cancel correctly.
2294
+ */
2295
+ async authFetch(url, init = {}) {
2296
+ const buildHeaders = () => {
2297
+ const h = new Headers(init.headers);
2298
+ h.set("Authorization", `Bearer ${this.sessionToken}`);
2299
+ return h;
2300
+ };
2301
+ let response = await fetch(url, { ...init, headers: buildHeaders() });
2302
+ if (response.status === 401) {
2303
+ const renewed = await this.tryRenew();
2304
+ if (renewed) {
2305
+ response = await fetch(url, { ...init, headers: buildHeaders() });
2306
+ }
2307
+ }
2308
+ return response;
2309
+ }
2011
2310
  handleSessionExpired() {
2012
2311
  document.cookie = "__lr_editor_active=; path=/; SameSite=Lax; Max-Age=0";
2013
2312
  this.onStatus("error", "Session expired. Please reopen the editor from the dashboard.");
@@ -2163,12 +2462,9 @@ var LionRapidEditor = (() => {
2163
2462
  */
2164
2463
  async transitionTarget(composite, action) {
2165
2464
  try {
2166
- const response = await fetch(
2465
+ const response = await this.authFetch(
2167
2466
  `${this.apiBase}/api/integrations/targets/${encodeURIComponent(composite)}/${action}`,
2168
- {
2169
- method: "POST",
2170
- headers: { "Authorization": `Bearer ${this.sessionToken}` }
2171
- }
2467
+ { method: "POST" }
2172
2468
  );
2173
2469
  if (response.status === 401) {
2174
2470
  this.handleSessionExpired();
@@ -2450,6 +2746,20 @@ var LionRapidEditor = (() => {
2450
2746
  }
2451
2747
 
2452
2748
  // src/browser/editor/editor-init.ts
2749
+ if (typeof window !== "undefined") {
2750
+ window.__lr_dump_keys = () => {
2751
+ const extractor = new BlockExtractor();
2752
+ const blocks = extractor.extract(document.body, true);
2753
+ const rows = blocks.map((b, i) => ({
2754
+ i,
2755
+ key: b.key,
2756
+ text: b.unit.text,
2757
+ stylesJson: JSON.stringify(b.unit.styles)
2758
+ }));
2759
+ console.table(rows);
2760
+ return rows;
2761
+ };
2762
+ }
2453
2763
  var SESSION_KEY = "__lr_editor_session";
2454
2764
  var PREVIEW_COOKIE = "__lr_editor_active";
2455
2765
  function setPreviewCookie() {
@@ -2645,7 +2955,6 @@ var LionRapidEditor = (() => {
2645
2955
  const stripped = url2.pathname.replace(/^\/[a-z]{2}(\/|$)/, "/");
2646
2956
  const prefix = newLocale === defaultLocale ? "" : `/${newLocale}`;
2647
2957
  url2.pathname = prefix + (stripped === "/" && prefix ? "" : stripped);
2648
- url2.searchParams.set("__lr_editor", grant);
2649
2958
  window.location.href = url2.toString();
2650
2959
  return;
2651
2960
  }
@@ -2699,11 +3008,37 @@ var LionRapidEditor = (() => {
2699
3008
  },
2700
3009
  // When in recapture mode, surface it to the toolbar so the button label
2701
3010
  // can change from "Capture" to "Save re-capture".
2702
- recaptureUuid ? { mode: "recapture" } : void 0
3011
+ recaptureUuid ? { mode: "recapture" } : void 0,
3012
+ // Browse-mode toggle. Suspending the inspector lets the PM click the
3013
+ // site's own links to reach another page; the session rides along via the
3014
+ // sessionStorage JWT + __lr_editor_active cookie, so the editor re-activates
3015
+ // on the destination. Disabled in snapshot viewer mode (static sandbox,
3016
+ // nothing to navigate to).
3017
+ snapshotUuid ? null : (browsing) => {
3018
+ inspector.setActive(!browsing);
3019
+ if (browsing) sidebar.close();
3020
+ }
2703
3021
  );
2704
3022
  sidebar.setProgressCallback((translated, total) => {
2705
3023
  toolbar.updateProgress(translated, total);
2706
3024
  });
3025
+ document.addEventListener("click", (e) => {
3026
+ if (e.defaultPrevented) return;
3027
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
3028
+ if (e.button !== 0) return;
3029
+ const target = e.target;
3030
+ if (!target?.closest) return;
3031
+ if (target.closest("#__lionrapid-editor")) return;
3032
+ const a = target.closest("a[href]");
3033
+ if (!a) return;
3034
+ if (a.target && a.target !== "_self") return;
3035
+ if (a.hasAttribute("download")) return;
3036
+ const localized = localizeUrl(a.href, defaultLocale);
3037
+ if (localized !== a.href) {
3038
+ e.preventDefault();
3039
+ window.location.href = localized;
3040
+ }
3041
+ });
2707
3042
  sidebar.hydrateStatus().then(() => {
2708
3043
  toolbar.updateProgress(sidebar.getTranslatedCount(), sidebar.getTotalCount());
2709
3044
  });