livelocale 0.12.1 → 1.0.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.
package/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  <img src="brand/logo.svg" alt="livelocale" height="64">
6
6
  </picture>
7
7
  </h1>
8
- <p><strong>Click any text in your app. Fix the translation. Ship it as a pull request.</strong></p>
9
- <p>Keep next-intl or react-i18next. LiveLocale adds in-context editing for Next.js and React + Vite: no account, no server, no browser extension. Your translations stay as JSON in git.</p>
8
+ <p><strong>The editing and review layer on top of next-intl or react-i18next.</strong></p>
9
+ <p>Your content people fix text on the real site, and you get each fix as an edit to your JSON locale files in git, or as a pull request. For Next.js and React + Vite: no account, no server, no browser extension.</p>
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://github.com/Araby99/livelocale/actions/workflows/ci.yml?query=branch%3Amain"><img src="https://github.com/Araby99/livelocale/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI Status" /></a>
@@ -18,6 +18,16 @@
18
18
 
19
19
  ---
20
20
 
21
+ **It is not an i18n library.** next-intl or react-i18next keeps doing routing, locale detection,
22
+ date and number formatting and message rendering; livelocale only edits and reviews the text.
23
+
24
+ - **Freelancing?** Your client fixes their own copy through a
25
+ [share link](https://livelocale.js.org/docs/guides/share-links.html), and you review a pull
26
+ request instead of collecting screenshots.
27
+ - **Only want CI?** [`npx livelocale check --hardcoded`](https://livelocale.js.org/docs/guides/ci-check.html)
28
+ works without the Studio. Translating on your own? The CI check alone catches missing keys and
29
+ hard-coded text.
30
+
21
31
  Run your app beside the livelocale Studio, hold Alt and click a line of text, and fix it. The save
22
32
  goes straight into your locale JSON file as an ordinary edit you commit. In development every
23
33
  translated string carries an invisible watermark, so the Studio knows which key you clicked; in
@@ -87,6 +97,20 @@ npx livelocale studio
87
97
  Open `http://localhost:3010`, hold Alt (Option on a Mac), click a line of text and press
88
98
  **Save Changes**. `git diff locales/` shows the fix.
89
99
 
100
+ ## Supported versions
101
+
102
+ | Needs | Tested in CI |
103
+ | ----------------------------------------- | ------------------------------------------ |
104
+ | Node.js 18 or later | Node.js 20 and 24; the built package on 18 |
105
+ | Next.js 13 or later | Next.js 16 |
106
+ | React 18 or later | React 18 |
107
+ | Vite 5 or later | Vite 8 |
108
+ | TypeScript 4.7 to 6 (`check --hardcoded`) | TypeScript 5 |
109
+
110
+ From 1.0 on, livelocale follows semantic versioning, and share links people already sent keep
111
+ working in newer versions. [Versioning and stability](https://livelocale.js.org/docs/reference/stability.html)
112
+ lists what that covers and what is internal.
113
+
90
114
  ## Docs
91
115
 
92
116
  - [Get started](https://livelocale.js.org/docs/): Next.js, Vite + React, or any static host.
@@ -94,6 +118,7 @@ Open `http://localhost:3010`, hold Alt (Option on a Mac), click a line of text a
94
118
  - [Share links](https://livelocale.js.org/docs/guides/share-links.html): fixes from the deployed site, no account and no install.
95
119
  - [Catch untranslated text in CI](https://livelocale.js.org/docs/guides/ci-check.html): missing keys, placeholder drift and hardcoded text.
96
120
  - [CLI reference](https://livelocale.js.org/docs/reference/cli/index.html): every command and flag.
121
+ - [Versioning and stability](https://livelocale.js.org/docs/reference/stability.html): what semver covers, and the versions CI tests.
97
122
 
98
123
  ## Contributing
99
124
 
@@ -194,6 +194,7 @@ function isLiveLocaleEnabled() {
194
194
  }
195
195
 
196
196
  // src/client/iframe-bridge.ts
197
+ var CLIENT_FEATURES = ["edit-mode"];
197
198
  var DEFAULT_STUDIO_ORIGIN = "http://localhost:3010";
198
199
  var STUDIO_ORIGIN_STORAGE_KEY = "livelocale-studio-origin";
199
200
  function toHttpOrigin(value) {
@@ -237,6 +238,8 @@ function resolveStudioOrigin(search, storage, sameOriginParent) {
237
238
  var MAX_VISIBLE_TEXTS = 100;
238
239
  var MAX_VISIBLE_TEXT_LENGTH = 200;
239
240
  var KEY_PATTERN = /^[a-zA-Z0-9._-]+$/;
241
+ var FEATURE_PATTERN = /^[a-z0-9-]+$/;
242
+ var MAX_FEATURES = 16;
240
243
  var MAX_LOCATION_LENGTH = 2048;
241
244
  var LOCATION_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/;
242
245
  var LOCATION_PROBE_ORIGIN = "http://livelocale.invalid";
@@ -291,7 +294,7 @@ var IframeBridge = class {
291
294
  return;
292
295
  }
293
296
  }
294
- if (type === "VISIBLE_KEYS_CHANGED") {
297
+ if (type === "VISIBLE_KEYS_CHANGED" || type === "RENDERED_KEYS_CHANGED") {
295
298
  if (!Array.isArray(payload)) return;
296
299
  const validKeys = payload.filter(
297
300
  (k) => typeof k === "string" && /^[a-zA-Z0-9._-]+$/.test(k)
@@ -314,7 +317,15 @@ var IframeBridge = class {
314
317
  }
315
318
  if (type === "READY" || type === "LOCATION_CHANGED") {
316
319
  const url = toLocationUrl(payload);
317
- payload = url === null ? {} : { url };
320
+ const next = url === null ? {} : { url };
321
+ const features = payload?.features;
322
+ if (type === "READY" && Array.isArray(features)) {
323
+ next.features = features.filter((f) => typeof f === "string" && FEATURE_PATTERN.test(f)).slice(0, MAX_FEATURES);
324
+ }
325
+ payload = next;
326
+ }
327
+ if (type === "TAP_UNKEYED") {
328
+ payload = void 0;
318
329
  }
319
330
  const message = {
320
331
  source: "livelocale-client",
@@ -366,9 +377,27 @@ function debounce(fn, wait) {
366
377
  };
367
378
  return debounced;
368
379
  }
380
+ var EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
381
+ var EDIT_STYLE_ID = "livelocale-edit-style";
382
+ var EDIT_ATTRIBUTE = "data-livelocale-edit";
383
+ var PICKED_ATTRIBUTE = "data-livelocale-picked";
384
+ var REVEAL_MARGIN = 16;
385
+ function isRendered(el) {
386
+ const visible = typeof el.checkVisibility === "function" ? el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) : el.getClientRects().length > 0;
387
+ if (!visible) return false;
388
+ const rect = el.getBoundingClientRect();
389
+ return rect.width > 0 && rect.height > 0;
390
+ }
391
+ function hasOwnText(el) {
392
+ for (const child of el.childNodes) {
393
+ if (child.nodeType === Node.TEXT_NODE && (child.nodeValue ?? "").trim() !== "") return true;
394
+ }
395
+ return false;
396
+ }
369
397
  var DOMInterceptor = class {
370
398
  onElementSelected;
371
399
  onVisibleKeysChanged;
400
+ onUnkeyedTap;
372
401
  hoveredElement = null;
373
402
  observer = null;
374
403
  inputSanitizersAttached = false;
@@ -377,11 +406,19 @@ var DOMInterceptor = class {
377
406
  overlayBadge = null;
378
407
  // ─── Alt key state ───────────────────────────────────────────────────────
379
408
  isAltHeld = false;
409
+ // ─── Edit mode (touch) ───────────────────────────────────────────────────
410
+ // Kept across destroy()/init() so the Studio's CLEAR_SELECTION, which
411
+ // re-inits the interceptor, never unfreezes the site.
412
+ editMode = false;
413
+ active = false;
414
+ /** The element carrying `PICKED_ATTRIBUTE`, if any. */
415
+ pickedElement = null;
380
416
  // Debounced key-broadcast to avoid flooding the parent on rapid DOM mutations
381
417
  broadcastVisibleKeys;
382
- constructor(onElementSelected, onVisibleKeysChanged) {
418
+ constructor(onElementSelected, onVisibleKeysChanged, onUnkeyedTap) {
383
419
  this.onElementSelected = onElementSelected;
384
420
  this.onVisibleKeysChanged = onVisibleKeysChanged;
421
+ this.onUnkeyedTap = onUnkeyedTap;
385
422
  this.broadcastVisibleKeys = debounce(() => {
386
423
  if (this.onVisibleKeysChanged) {
387
424
  this.onVisibleKeysChanged(this.getActiveKeys());
@@ -429,6 +466,75 @@ var DOMInterceptor = class {
429
466
  }
430
467
  return texts;
431
468
  }
469
+ /**
470
+ * Returns the unique keys that have at least one rendered, visible element
471
+ * (see `isRendered`), in document order. Keys only inside closed menus,
472
+ * hidden slides or a layout hidden at this width are left out.
473
+ */
474
+ getRenderedKeys() {
475
+ const keys = /* @__PURE__ */ new Set();
476
+ document.querySelectorAll("[data-i18n-key]").forEach((el) => {
477
+ const key = el.getAttribute("data-i18n-key");
478
+ if (key && !keys.has(key) && isRendered(el)) keys.add(key);
479
+ });
480
+ return Array.from(keys);
481
+ }
482
+ /** The first rendered, visible element carrying `key`, else `null`. */
483
+ findRenderedElement(key) {
484
+ for (const el of document.querySelectorAll("[data-i18n-key]")) {
485
+ if (el.getAttribute("data-i18n-key") === key && isRendered(el)) return el;
486
+ }
487
+ return null;
488
+ }
489
+ /**
490
+ * Scrolls the first visible element with `key` to the centre and selects
491
+ * it as a tap would. Returns false, selecting nothing, when no element with
492
+ * that key is visible or the interceptor is not running. A key that is no
493
+ * longer visible (a carousel moved on with no DOM change the observer sees)
494
+ * reports the keys again, so the Studio's next try uses a fresh list.
495
+ */
496
+ selectKey(key) {
497
+ if (!this.active) return false;
498
+ const el = this.findRenderedElement(key);
499
+ if (!el) {
500
+ this.broadcastVisibleKeys();
501
+ return false;
502
+ }
503
+ if (typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center" });
504
+ this.select(el, key);
505
+ return true;
506
+ }
507
+ /**
508
+ * Scrolls so the first visible element with `key` sits fully between the
509
+ * frame's top and `bottomInset` CSS px above its bottom (where the Studio's
510
+ * sheet starts), without selecting it. Does nothing when it already does,
511
+ * or when no element with that key is visible. A tall element keeps its top
512
+ * in view rather than its bottom.
513
+ */
514
+ revealKey(key, bottomInset) {
515
+ if (!this.active) return;
516
+ const el = this.findRenderedElement(key);
517
+ if (!el) return;
518
+ const rect = el.getBoundingClientRect();
519
+ const limit = window.innerHeight - bottomInset;
520
+ if (rect.top >= 0 && rect.bottom <= limit) return;
521
+ const delta = Math.min(rect.bottom - limit + REVEAL_MARGIN, rect.top - REVEAL_MARGIN);
522
+ window.scrollBy(0, delta);
523
+ }
524
+ /**
525
+ * Turns Edit mode on or off. In Edit mode no tap reaches the site: a tap on
526
+ * keyed text selects it, a tap on other text calls `onUnkeyedTap`, and
527
+ * anything else does nothing. Scrolling still works. The mode is kept on
528
+ * the instance and re-applied by `init()`.
529
+ */
530
+ setEditMode(on) {
531
+ const changed = this.editMode !== on;
532
+ this.editMode = on;
533
+ if (!this.active) return;
534
+ if (on) this.attachEditMode();
535
+ else this.detachEditMode();
536
+ if (changed) this.broadcastVisibleKeys();
537
+ }
432
538
  /**
433
539
  * Initializes the DOM event interceptors in development mode.
434
540
  * Also launches the watermark scanner and MutationObserver.
@@ -453,6 +559,8 @@ var DOMInterceptor = class {
453
559
  this.scanSubtree(document.body);
454
560
  this.attachMutationObserver();
455
561
  this.attachInputSanitizers();
562
+ this.active = true;
563
+ if (this.editMode) this.attachEditMode();
456
564
  this.broadcastVisibleKeys();
457
565
  }
458
566
  /**
@@ -473,6 +581,8 @@ var DOMInterceptor = class {
473
581
  window.removeEventListener("keydown", this.handleKeyDown, true);
474
582
  window.removeEventListener("keyup", this.handleKeyUp, true);
475
583
  window.removeEventListener("beforeinput", this.handleBeforeInput, true);
584
+ this.detachEditMode();
585
+ this.active = false;
476
586
  document.removeEventListener("submit", this.handleFormSubmit, true);
477
587
  if (this.observer) {
478
588
  this.observer.disconnect();
@@ -487,6 +597,87 @@ var DOMInterceptor = class {
487
597
  this.hoveredElement = null;
488
598
  this.broadcastVisibleKeys.cancel();
489
599
  }
600
+ // ─── Edit Mode ───────────────────────────────────────────────────────────
601
+ attachEditMode() {
602
+ window.addEventListener("pointerdown", this.stopFromSite, true);
603
+ window.addEventListener("pointerup", this.stopFromSite, true);
604
+ window.addEventListener("mousedown", this.stopFromSite, true);
605
+ window.addEventListener("mouseup", this.stopFromSite, true);
606
+ window.addEventListener("touchstart", this.stopFromSite, { capture: true, passive: true });
607
+ window.addEventListener("touchend", this.stopFromSite, { capture: true, passive: true });
608
+ window.addEventListener("focusin", this.handleEditFocus, true);
609
+ if (!document.getElementById(EDIT_STYLE_ID)) {
610
+ const style = document.createElement("style");
611
+ style.id = EDIT_STYLE_ID;
612
+ style.textContent = `html[${EDIT_ATTRIBUTE}] [data-i18n-key]{outline:1px dashed rgba(79,195,220,.7);outline-offset:2px}html[${EDIT_ATTRIBUTE}] [data-i18n-key][${PICKED_ATTRIBUTE}]{outline:2px solid #FFB224;outline-offset:3px}`;
613
+ document.head.appendChild(style);
614
+ }
615
+ document.documentElement.setAttribute(EDIT_ATTRIBUTE, "");
616
+ this.hideOverlay();
617
+ this.hoveredElement = null;
618
+ }
619
+ detachEditMode() {
620
+ window.removeEventListener("pointerdown", this.stopFromSite, true);
621
+ window.removeEventListener("pointerup", this.stopFromSite, true);
622
+ window.removeEventListener("mousedown", this.stopFromSite, true);
623
+ window.removeEventListener("mouseup", this.stopFromSite, true);
624
+ window.removeEventListener("touchstart", this.stopFromSite, true);
625
+ window.removeEventListener("touchend", this.stopFromSite, true);
626
+ window.removeEventListener("focusin", this.handleEditFocus, true);
627
+ document.documentElement.removeAttribute(EDIT_ATTRIBUTE);
628
+ document.getElementById(EDIT_STYLE_ID)?.remove();
629
+ this.clearPicked();
630
+ }
631
+ /** Moves the picked-text marker to `element`. */
632
+ markPicked(element) {
633
+ this.clearPicked();
634
+ element.setAttribute(PICKED_ATTRIBUTE, "");
635
+ this.pickedElement = element;
636
+ }
637
+ clearPicked() {
638
+ this.pickedElement?.removeAttribute(PICKED_ATTRIBUTE);
639
+ this.pickedElement = null;
640
+ }
641
+ // Sites open menus on press events; in Edit mode none of them reaches the
642
+ // site. Never preventDefault here: on touchstart it would stop scrolling.
643
+ stopFromSite = (event) => {
644
+ event.stopImmediatePropagation();
645
+ };
646
+ handleEditFocus = (event) => {
647
+ const target = event.target;
648
+ if (!target || typeof target.matches !== "function") return;
649
+ if (target.matches(EDITABLE_SELECTOR) || target.isContentEditable) target.blur();
650
+ };
651
+ /** Edit-mode click: cancelled whatever the target, then a selection or the notice. */
652
+ handleEditClick(event) {
653
+ event.preventDefault();
654
+ event.stopImmediatePropagation();
655
+ const target = event.target;
656
+ if (!target || typeof target.closest !== "function") return;
657
+ const keyedElement = target.closest("[data-i18n-key]");
658
+ const key = keyedElement?.getAttribute("data-i18n-key");
659
+ if (keyedElement && key) {
660
+ this.select(keyedElement, key);
661
+ } else if (hasOwnText(target)) {
662
+ this.onUnkeyedTap?.();
663
+ }
664
+ }
665
+ /**
666
+ * Reports `element`, which carries `key`, as selected with its clean text. In Edit mode it
667
+ * also gets the picked-text marker; an Alt+Click pick on a desktop does not.
668
+ */
669
+ select(element, key) {
670
+ if (this.editMode) this.markPicked(element);
671
+ const rawText = element.innerText || "";
672
+ const cleanText = stripWatermark(rawText);
673
+ const fallbackValue = element.getAttribute("data-i18n-template") || cleanText;
674
+ const currentValue = cleanText;
675
+ this.onElementSelected({
676
+ key,
677
+ fallbackValue,
678
+ currentValue
679
+ });
680
+ }
490
681
  // ─── Floating Overlay ────────────────────────────────────────────────────
491
682
  /**
492
683
  * Creates and injects a single floating overlay div into the document body.
@@ -730,6 +921,7 @@ var DOMInterceptor = class {
730
921
  };
731
922
  // ─── Mouse / Click Handlers ───────────────────────────────────────────────
732
923
  handleMouseOver = (event) => {
924
+ if (this.editMode) return;
733
925
  const target = event.target;
734
926
  if (!target || typeof target.closest !== "function") return;
735
927
  const keyedElement = target.closest("[data-i18n-key]");
@@ -758,6 +950,10 @@ var DOMInterceptor = class {
758
950
  this.hoveredElement = null;
759
951
  };
760
952
  handleClick = (event) => {
953
+ if (this.editMode) {
954
+ this.handleEditClick(event);
955
+ return;
956
+ }
761
957
  if (!event.altKey) return;
762
958
  const target = event.target;
763
959
  if (!target || typeof target.closest !== "function") return;
@@ -767,15 +963,7 @@ var DOMInterceptor = class {
767
963
  if (key === null) return;
768
964
  event.preventDefault();
769
965
  event.stopPropagation();
770
- const rawText = keyedElement.innerText || "";
771
- const cleanText = stripWatermark(rawText);
772
- const fallbackValue = keyedElement.getAttribute("data-i18n-template") || cleanText;
773
- const currentValue = cleanText;
774
- this.onElementSelected({
775
- key,
776
- fallbackValue,
777
- currentValue
778
- });
966
+ this.select(keyedElement, key);
779
967
  };
780
968
  handleCopy = (event) => {
781
969
  const selection = window.getSelection()?.toString() || "";
@@ -1036,16 +1224,22 @@ if (typeof window !== "undefined" && isLiveLocaleEnabled() && window.parent !==
1036
1224
  },
1037
1225
  (keys) => {
1038
1226
  bridge.sendToParent("VISIBLE_KEYS_CHANGED", keys);
1227
+ bridge.sendToParent("RENDERED_KEYS_CHANGED", interceptor.getRenderedKeys());
1039
1228
  bridge.sendToParent("VISIBLE_TEXTS_CHANGED", interceptor.getVisibleTexts());
1229
+ },
1230
+ () => {
1231
+ bridge.sendToParent("TAP_UNKEYED");
1040
1232
  }
1041
1233
  );
1042
1234
  interceptor.init();
1043
1235
  const locationUrl = () => window.location.pathname + window.location.search + window.location.hash;
1044
- bridge.sendToParent("READY", { url: locationUrl() });
1236
+ bridge.sendToParent("READY", { url: locationUrl(), features: CLIENT_FEATURES });
1045
1237
  watchLocation(window, (url) => bridge.sendToParent("LOCATION_CHANGED", { url }));
1046
1238
  window.addEventListener("pagehide", () => bridge.sendToParent("LEAVING"));
1047
1239
  window.addEventListener("pageshow", (event) => {
1048
- if (event.persisted) bridge.sendToParent("READY", { url: locationUrl() });
1240
+ if (event.persisted) {
1241
+ bridge.sendToParent("READY", { url: locationUrl(), features: CLIENT_FEATURES });
1242
+ }
1049
1243
  });
1050
1244
  bridge.listenToParent((type, payload) => {
1051
1245
  if (type === "APPLY_PREVIEW") {
@@ -1063,6 +1257,18 @@ if (typeof window !== "undefined" && isLiveLocaleEnabled() && window.parent !==
1063
1257
  } else if (type === "NAVIGATE") {
1064
1258
  const href = navigationHref(payload, window.location.origin);
1065
1259
  if (href !== null) followNavigation(window.location, href);
1260
+ } else if (type === "SET_EDIT_MODE") {
1261
+ if (payload && typeof payload.on === "boolean") {
1262
+ interceptor.setEditMode(payload.on);
1263
+ }
1264
+ } else if (type === "SELECT_KEY") {
1265
+ if (payload && typeof payload.key === "string") {
1266
+ interceptor.selectKey(payload.key);
1267
+ }
1268
+ } else if (type === "REVEAL_KEY") {
1269
+ if (payload && typeof payload.key === "string" && typeof payload.bottomInset === "number" && Number.isFinite(payload.bottomInset) && payload.bottomInset >= 0) {
1270
+ interceptor.revealKey(payload.key, payload.bottomInset);
1271
+ }
1066
1272
  }
1067
1273
  });
1068
1274
  window.__LIVELOCALE_CLIENT__ = {