@upstart.gg/vite-plugins 0.1.68 → 0.1.70

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 (52) hide show
  1. package/dist/upstart-editor-api.d.ts +2 -0
  2. package/dist/upstart-editor-api.d.ts.map +1 -1
  3. package/dist/upstart-editor-api.js +67 -9
  4. package/dist/upstart-editor-api.js.map +1 -1
  5. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  6. package/dist/vite-plugin-upstart-attrs.js +18 -1
  7. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  8. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js +7 -7
  9. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -1
  10. package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts.map +1 -1
  11. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +30 -16
  12. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  13. package/dist/vite-plugin-upstart-editor/runtime/dom-guard.js +45 -0
  14. package/dist/vite-plugin-upstart-editor/runtime/dom-guard.js.map +1 -0
  15. package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts +7 -1
  16. package/dist/vite-plugin-upstart-editor/runtime/error-handler.d.ts.map +1 -1
  17. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js +42 -2
  18. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js.map +1 -1
  19. package/dist/vite-plugin-upstart-editor/runtime/form-guard.js +2 -2
  20. package/dist/vite-plugin-upstart-editor/runtime/form-guard.js.map +1 -1
  21. package/dist/vite-plugin-upstart-editor/runtime/image-pick.js +32 -0
  22. package/dist/vite-plugin-upstart-editor/runtime/image-pick.js.map +1 -0
  23. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  24. package/dist/vite-plugin-upstart-editor/runtime/index.js +9 -1
  25. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  26. package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts +32 -0
  27. package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts.map +1 -0
  28. package/dist/vite-plugin-upstart-editor/runtime/labels.js +45 -0
  29. package/dist/vite-plugin-upstart-editor/runtime/labels.js.map +1 -0
  30. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
  31. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +26 -14
  32. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
  33. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +14 -1
  34. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  35. package/package.json +3 -3
  36. package/src/tests/dom-guard.test.ts +100 -0
  37. package/src/tests/editor-labels.test.ts +40 -0
  38. package/src/tests/image-pick.test.ts +50 -0
  39. package/src/tests/upstart-editor-api.test.ts +75 -0
  40. package/src/tests/vite-plugin-upstart-attrs.test.ts +21 -0
  41. package/src/upstart-editor-api.ts +74 -8
  42. package/src/vite-plugin-upstart-attrs.ts +44 -1
  43. package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +7 -7
  44. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +29 -19
  45. package/src/vite-plugin-upstart-editor/runtime/dom-guard.ts +67 -0
  46. package/src/vite-plugin-upstart-editor/runtime/error-handler.ts +48 -1
  47. package/src/vite-plugin-upstart-editor/runtime/form-guard.ts +3 -2
  48. package/src/vite-plugin-upstart-editor/runtime/image-pick.ts +40 -0
  49. package/src/vite-plugin-upstart-editor/runtime/index.ts +11 -0
  50. package/src/vite-plugin-upstart-editor/runtime/labels.ts +77 -0
  51. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +39 -15
  52. package/src/vite-plugin-upstart-editor/runtime/types.ts +12 -1
@@ -1,4 +1,5 @@
1
1
  import { PayloadArrayItemAdd, PayloadArrayItemDelete, PayloadArraySet, PayloadEditText, PayloadEditTextDirect } from "../../upstart-editor-api.js";
2
+ import { RuntimeLabels } from "./labels.js";
2
3
  import { Editor } from "@tiptap/core";
3
4
 
4
5
  //#region src/vite-plugin-upstart-editor/runtime/types.d.ts
@@ -101,6 +102,15 @@ type EditorMessage = {
101
102
  } | {
102
103
  type: "editor-error";
103
104
  error: string;
105
+ }
106
+ /**
107
+ * React's DOM reconciliation hit nodes the editor had replaced. `fatal: false`: the page was
108
+ * kept alive by the DOM guard (a refresh is advisable); `fatal: true`: the page crashed and
109
+ * the preview shows its recovery overlay.
110
+ */
111
+ | {
112
+ type: "editor-desync";
113
+ fatal: boolean;
104
114
  } | {
105
115
  type: "selection-changed";
106
116
  selection: SelectionRef[];
@@ -149,6 +159,9 @@ type ParentMessage = {
149
159
  } | {
150
160
  type: "deselect";
151
161
  key: string;
162
+ } /** Translated labels for the controls the runtime renders (see `runtime/labels.ts`). */ | {
163
+ type: "set-labels";
164
+ labels: Partial<RuntimeLabels>;
152
165
  };
153
166
  /**
154
167
  * Message wrapper sent via postMessage from iframe.
@@ -224,5 +237,5 @@ interface UpstartEditorPluginOptions {
224
237
  autoInject?: boolean;
225
238
  }
226
239
  //#endregion
227
- export { EditableImageRef, EditorInstance, EditorMessage, EditorMode, ElementBounds, ParentMessage, SelectionRef, TextEditorMode, UpstartEditorMessage, UpstartEditorOptions, UpstartEditorPluginOptions, UpstartParentMessage };
240
+ export { EditableImageRef, EditorInstance, EditorMessage, EditorMode, ElementBounds, ParentMessage, type RuntimeLabels, SelectionRef, TextEditorMode, UpstartEditorMessage, UpstartEditorOptions, UpstartEditorPluginOptions, UpstartParentMessage };
228
241
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/vite-plugin-upstart-editor/runtime/types.ts"],"mappings":";;;;;;AAeA;;;;KAAY,UAAA;AAOZ;;;;;AAAA,UAAiB,YAAA;EACf,GAAA;EACA,IAAA;EACA,QAAA;EACA,aAAA;EACA,QAAA;EACA,IAAA;EAMS;AAWX;;;;EAXE,SAAA;AAAA;;;;;;;;;KAWU,cAAA;;;AAiBZ;UAZiB,aAAA;EACf,GAAA;EACA,IAAA;EACA,KAAA;EACA,MAAA;EACA,KAAA;EACA,MAAA;AAAA;;;;UAMe,cAAA;EACf,MAAA,EAAQ,MAAA;EACR,OAAA,EAAS,WAAA;EACT,IAAA;EACA,IAAA,EAAM,cAAA;AAAA;;;;;UAOS,gBAAA;EACf,EAAA;EACA,GAAA;EACA,GAAA;EAKA;EAHA,WAAA;EACA,gBAAA;EAKA;EAHA,KAAA;EACA,MAAA;EACA,YAAA;EACA,aAAA;EAKe;EAHf,OAAA;EASuB;EAPvB,iBAAA;EACA,eAAA;AAAA;;;;KAMU,aAAA;EAEN,IAAA;EACA,OAAA,EACI,eAAA,GACA,qBAAA,GACA,mBAAA,GACA,sBAAA,GACA,eAAA;AAAA;EAEJ,IAAA;EAAsB,IAAA;AAAA;EACtB,IAAA;EAA0B,IAAA;AAAA;EAE1B,IAAA;EAAsB,OAAA;AAAA;EACtB,IAAA;EAAyB,CAAA;EAAW,CAAA;AAAA;EACpC,IAAA;EAAsB,KAAA;AAAA;EACtB,IAAA;EAA2B,SAAA,EAAW,YAAA;AAAA;EAEtC,IAAA;EACA,IAAA;EACA,aAAA;EACA,QAAA;EACA,gBAAA;EACA,WAAA;EACA,YAAA;EACA,QAAA;EACA,WAAA,GAAc,MAAA;EACd,MAAA,EAAQ,aAAA;EACR,aAAA,WAFc;EAId,OAAA,WAHQ;EAKR,MAAA,GAAS,gBAAA;AAAA;;;;KAMH,aAAA;EACN,IAAA;EAAkB,IAAA,EAAM,UAAA;AAAA;EACxB,IAAA;EAA2B,WAAA;EAAqB,SAAA;EAAmB,UAAA;AAAA;EACnE,IAAA;EAAuB,OAAA;EAAiB,GAAA;AAAA;EAExC,IAAA;EAA6B,OAAA;EAAiB,GAAA;AAAA;EAC9C,IAAA;AAAA;EACA,IAAA;EAAiC,CAAA;EAAW,CAAA;AAAA;EAC5C,IAAA;AAAA;EACA,IAAA;EAAkB,GAAA;AAAA;;;;UAKP,oBAAA;EACf,MAAA;EACA,IAAA,EAAM,aAAA;EAAA,CACL,GAAA;AAAA;;;;UAMc,oBAAA;EACf,MAAA;EACA,IAAA,EAAM,aAAA;EAAA,CACL,GAAA;AAAA;;AAMH;;UAAiB,oBAAA;EAAoB;;;;EAKnC,gBAAA;EAyBA;;;;;EAlBA,sBAAA;EA+BoD;AAMtD;;;EA/BE,iBAAA;EA0CU;;;;EApCV,UAAA;;;;;EAMA,WAAA;;;;;EAMA,aAAA;;;;;;EAOA,kBAAA,IAAsB,SAAA,UAAmB,GAAA;AAAA;;;;UAM1B,0BAAA;;;;;EAKf,OAAA;;;;;EAMA,UAAA;AAAA"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/vite-plugin-upstart-editor/runtime/types.ts"],"mappings":";;;;;;AAkBA;;;;;KAAY,UAAA;;;;;;UAOK,YAAA;EACf,GAAA;EACA,IAAA;EACA,QAAA;EACA,aAAA;EACA,QAAA;EACA,IAAA;EAiBU;;;;;EAXV,SAAA;AAAA;;;;;;;;;KAWU,cAAA;;AAiBZ;;UAZiB,aAAA;EACf,GAAA;EACA,IAAA;EACA,KAAA;EACA,MAAA;EACA,KAAA;EACA,MAAA;AAAA;;;;UAMe,cAAA;EACf,MAAA,EAAQ,MAAA;EACR,OAAA,EAAS,WAAA;EACT,IAAA;EACA,IAAA,EAAM,cAAA;AAAA;;;;;UAOS,gBAAA;EACf,EAAA;EACA,GAAA;EACA,GAAA;EAMA;EAJA,WAAA;EACA,gBAAA;EAOA;EALA,KAAA;EACA,MAAA;EACA,YAAA;EACA,aAAA;EAWU;EATV,OAAA;;EAEA,iBAAA;EACA,eAAA;AAAA;;;;KAMU,aAAA;EAEN,IAAA;EACA,OAAA,EACI,eAAA,GACA,qBAAA,GACA,mBAAA,GACA,sBAAA,GACA,eAAA;AAAA;EAEJ,IAAA;EAAsB,IAAA;AAAA;EACtB,IAAA;EAA0B,IAAA;AAAA;EAE1B,IAAA;EAAsB,OAAA;AAAA;EACtB,IAAA;EAAyB,CAAA;EAAW,CAAA;AAAA;EACpC,IAAA;EAAsB,KAAA;AAAA;;;;;;;EAMtB,IAAA;EAAuB,KAAA;AAAA;EACvB,IAAA;EAA2B,SAAA,EAAW,YAAA;AAAA;EAEtC,IAAA;EACA,IAAA;EACA,aAAA;EACA,QAAA;EACA,gBAAA;EACA,WAAA;EACA,YAAA;EACA,QAAA;EACA,WAAA,GAAc,MAAA;EACd,MAAA,EAAQ,aAAA;EACR,aAAA,WAIyB;EAFzB,OAAA,WAQmB;EANnB,MAAA,GAAS,gBAAA;AAAA;;;;KAMH,aAAA;EACN,IAAA;EAAkB,IAAA,EAAM,UAAA;AAAA;EACxB,IAAA;EAA2B,WAAA;EAAqB,SAAA;EAAmB,UAAA;AAAA;EACnE,IAAA;EAAuB,OAAA;EAAiB,GAAA;AAAA;EAExC,IAAA;EAA6B,OAAA;EAAiB,GAAA;AAAA;EAC9C,IAAA;AAAA;EACA,IAAA;EAAiC,CAAA;EAAW,CAAA;AAAA;EAC5C,IAAA;AAAA;EACA,IAAA;EAAkB,GAAA;AAAA;EAElB,IAAA;EAAoB,MAAA,EAAQ,OAAA,CAAQ,aAAA;AAAA;;;AAc1C;UATiB,oBAAA;EACf,MAAA;EACA,IAAA,EAAM,aAAA;EAAA,CACL,GAAA;AAAA;;;;UAMc,oBAAA;EACf,MAAA;EACA,IAAA,EAAM,aAAA;EAAA,CACL,GAAA;AAAA;;;;UAMc,oBAAA;EA8Bf;;;;EAzBA,gBAAA;EAsCoD;;AAMtD;;;EArCE,sBAAA;EAgDU;;;;EA1CV,iBAAA;;;;;EAMA,UAAA;;;;;EAMA,WAAA;;;;;EAMA,aAAA;;;;;;EAOA,kBAAA,IAAsB,SAAA,UAAmB,GAAA;AAAA;;;;UAM1B,0BAAA;;;;;EAKf,OAAA;;;;;EAMA,UAAA;AAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upstart.gg/vite-plugins",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -23,7 +23,7 @@
23
23
  "oxc-parser": "0.101.0",
24
24
  "unplugin": "^2.3.11",
25
25
  "zimmerframe": "^1.1.4",
26
- "@upstart.gg/sdk": "^0.1.68"
26
+ "@upstart.gg/sdk": "^0.1.70"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@rolldown/binding-linux-arm64-gnu": "1.0.0",
@@ -118,7 +118,7 @@
118
118
  },
119
119
  "peerDependencies": {
120
120
  "zod": "4.3.6",
121
- "@upstart.gg/sdk": "^0.1.68"
121
+ "@upstart.gg/sdk": "^0.1.70"
122
122
  },
123
123
  "author": "Upstart",
124
124
  "publishConfig": {
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { installDomGuard, type DomLike } from "../vite-plugin-upstart-editor/runtime/dom-guard";
3
+
4
+ /** Minimal parent/child model standing in for DOM nodes (vitest runs in node here). */
5
+ class FakeNode {
6
+ parentNode: FakeNode | null = null;
7
+ children: FakeNode[] = [];
8
+ }
9
+
10
+ function makeProto() {
11
+ const insertBefore = vi.fn(function (this: FakeNode, node: FakeNode, child: FakeNode | null) {
12
+ const idx = child ? this.children.indexOf(child) : -1;
13
+ if (child && idx === -1) throw new Error("NotFoundError: insertBefore");
14
+ if (child) this.children.splice(idx, 0, node);
15
+ else this.children.push(node);
16
+ node.parentNode = this;
17
+ return node;
18
+ });
19
+ const removeChild = vi.fn(function (this: FakeNode, child: FakeNode) {
20
+ const idx = this.children.indexOf(child);
21
+ if (idx === -1) throw new Error("NotFoundError: removeChild");
22
+ this.children.splice(idx, 1);
23
+ child.parentNode = null;
24
+ return child;
25
+ });
26
+ return { insertBefore, removeChild } as unknown as DomLike & { insertBefore: typeof insertBefore };
27
+ }
28
+
29
+ describe("installDomGuard", () => {
30
+ test("keeps normal inserts and removals untouched and silent", () => {
31
+ const proto = makeProto();
32
+ const onDesync = vi.fn();
33
+ installDomGuard(proto, onDesync);
34
+ const parent = new FakeNode();
35
+ const a = new FakeNode();
36
+ const b = new FakeNode();
37
+ (proto.insertBefore as unknown as (this: FakeNode, n: FakeNode, c: FakeNode | null) => FakeNode).call(
38
+ parent,
39
+ a,
40
+ null,
41
+ );
42
+ (proto.insertBefore as unknown as (this: FakeNode, n: FakeNode, c: FakeNode | null) => FakeNode).call(
43
+ parent,
44
+ b,
45
+ a,
46
+ );
47
+ expect(parent.children).toEqual([b, a]);
48
+ (proto.removeChild as unknown as (this: FakeNode, c: FakeNode) => FakeNode).call(parent, b);
49
+ expect(parent.children).toEqual([a]);
50
+ expect(onDesync).not.toHaveBeenCalled();
51
+ });
52
+
53
+ test("turns an insert before a vanished node into an append and reports once", () => {
54
+ const proto = makeProto();
55
+ const onDesync = vi.fn();
56
+ installDomGuard(proto, onDesync);
57
+ const parent = new FakeNode();
58
+ const existing = new FakeNode();
59
+ parent.children.push(existing);
60
+ existing.parentNode = parent;
61
+ const gone = new FakeNode(); // removed by the editor, React still references it
62
+ const fresh = new FakeNode();
63
+ const insert = proto.insertBefore as unknown as (
64
+ this: FakeNode,
65
+ n: FakeNode,
66
+ c: FakeNode | null,
67
+ ) => FakeNode;
68
+ expect(() => insert.call(parent, fresh, gone)).not.toThrow();
69
+ expect(parent.children).toEqual([existing, fresh]);
70
+ expect(() => insert.call(parent, new FakeNode(), gone)).not.toThrow();
71
+ expect(onDesync).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ test("logs every tolerated call with its stack so the culprit can be found", () => {
75
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
76
+ const proto = makeProto();
77
+ installDomGuard(proto, () => {});
78
+ const parent = new FakeNode();
79
+ const gone = new FakeNode();
80
+ const remove = proto.removeChild as unknown as (this: FakeNode, c: FakeNode) => FakeNode;
81
+ remove.call(parent, gone);
82
+ remove.call(parent, gone);
83
+ expect(warn).toHaveBeenCalledTimes(2);
84
+ expect(String(warn.mock.calls[0]?.[0])).toContain("DOM out of sync: removeChild");
85
+ expect(warn.mock.calls[0]?.[1]).toMatchObject({ parent, node: gone });
86
+ expect(String((warn.mock.calls[0]?.[1] as { stack: string }).stack)).toContain("DOM out of sync");
87
+ warn.mockRestore();
88
+ });
89
+
90
+ test("ignores the removal of a node that is no longer a child", () => {
91
+ const proto = makeProto();
92
+ const onDesync = vi.fn();
93
+ installDomGuard(proto, onDesync);
94
+ const parent = new FakeNode();
95
+ const gone = new FakeNode();
96
+ const remove = proto.removeChild as unknown as (this: FakeNode, c: FakeNode) => FakeNode;
97
+ expect(remove.call(parent, gone)).toBe(gone);
98
+ expect(onDesync).toHaveBeenCalledTimes(1);
99
+ });
100
+ });
@@ -0,0 +1,40 @@
1
+ import { afterEach, describe, expect, test } from "vitest";
2
+ import {
3
+ DEFAULT_LABELS,
4
+ getLabels,
5
+ resetLabels,
6
+ setLabels,
7
+ } from "../vite-plugin-upstart-editor/runtime/labels";
8
+
9
+ describe("editor runtime labels", () => {
10
+ afterEach(() => resetLabels());
11
+
12
+ test("starts with the English defaults", () => {
13
+ expect(getLabels()).toEqual(DEFAULT_LABELS);
14
+ });
15
+
16
+ test("overrides only the provided keys", () => {
17
+ setLabels({ addItem: "Ajouter un élément", clickToEdit: "Cliquez pour modifier..." });
18
+ expect(getLabels().addItem).toBe("Ajouter un élément");
19
+ expect(getLabels().clickToEdit).toBe("Cliquez pour modifier...");
20
+ expect(getLabels().removeItem).toBe(DEFAULT_LABELS.removeItem);
21
+ });
22
+
23
+ test("ignores unknown keys, empty strings and non-string values", () => {
24
+ setLabels({ addItem: "", removeItem: 42, unknown: "x" });
25
+ expect(getLabels()).toEqual(DEFAULT_LABELS);
26
+ expect("unknown" in getLabels()).toBe(false);
27
+ });
28
+
29
+ test("ignores malformed payloads", () => {
30
+ setLabels(null);
31
+ setLabels("labels");
32
+ expect(getLabels()).toEqual(DEFAULT_LABELS);
33
+ });
34
+
35
+ test("resetLabels restores the defaults", () => {
36
+ setLabels({ refresh: "Rafraîchir" });
37
+ resetLabels();
38
+ expect(getLabels().refresh).toBe(DEFAULT_LABELS.refresh);
39
+ });
40
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { pickImagesForClick, rectContainsPoint } from "../vite-plugin-upstart-editor/runtime/image-pick";
3
+
4
+ const img = (id: string, left: number, top: number, size = 100) => ({
5
+ id,
6
+ rect: { left, top, width: size, height: size },
7
+ });
8
+
9
+ describe("rectContainsPoint", () => {
10
+ test("includes edges", () => {
11
+ const rect = { left: 10, top: 10, width: 20, height: 20 };
12
+ expect(rectContainsPoint(rect, { x: 10, y: 10 })).toBe(true);
13
+ expect(rectContainsPoint(rect, { x: 30, y: 30 })).toBe(true);
14
+ expect(rectContainsPoint(rect, { x: 31, y: 20 })).toBe(false);
15
+ });
16
+ });
17
+
18
+ describe("pickImagesForClick", () => {
19
+ test("a clicked element without images does not surface a sibling image (navbar title vs logo)", () => {
20
+ const logo = img("logo", 0, 0, 40);
21
+ expect(pickImagesForClick([], [[logo], [logo]], { x: 200, y: 20 })).toEqual([]);
22
+ });
23
+
24
+ test("a click visually on a hero covered by an overlay surfaces that hero", () => {
25
+ const hero = img("hero", 0, 0, 800);
26
+ expect(pickImagesForClick([], [[], [hero]], { x: 400, y: 300 })).toEqual([hero]);
27
+ });
28
+
29
+ test("ancestors never surface every image on the page", () => {
30
+ const a = img("a", 0, 0);
31
+ const b = img("b", 500, 500);
32
+ expect(pickImagesForClick([], [[a, b]], { x: 550, y: 550 })).toEqual([b]);
33
+ expect(pickImagesForClick([], [[a, b]], { x: 300, y: 300 })).toEqual([]);
34
+ });
35
+
36
+ test("images inside the clicked element are all candidates, narrowed to the one under the click", () => {
37
+ const a = img("a", 0, 0);
38
+ const b = img("b", 200, 0);
39
+ // Click on the container's padding: both images belong to what was clicked.
40
+ expect(pickImagesForClick([a, b], [], { x: 150, y: 50 })).toEqual([a, b]);
41
+ // Click on image b itself.
42
+ expect(pickImagesForClick([a, b], [], { x: 250, y: 50 })).toEqual([b]);
43
+ });
44
+
45
+ test("without a click point, own images are returned and ancestors are ignored", () => {
46
+ const a = img("a", 0, 0);
47
+ expect(pickImagesForClick([a], [], undefined)).toEqual([a]);
48
+ expect(pickImagesForClick([], [[a]], undefined)).toEqual([]);
49
+ });
50
+ });
@@ -170,6 +170,81 @@ describe("UpstartEditorAPI", () => {
170
170
  expect(updatedContent).toBe(`<div className="px-8 py-4 bg-blue-500">Hello</div>`);
171
171
  });
172
172
 
173
+ test("resolves a stale id by previousClassName once the registry was regenerated", async () => {
174
+ // A previous edit enlarged the parent's className, so the rebuild moved the image's
175
+ // className literal (and its `file:offset` id) while the preview still uses the old id.
176
+ const sourceContent = `<div className="wrap overflow-hidden"><img className="img object-cover" /></div>`;
177
+ const sourcePath = path.join(tempDir, "test.tsx");
178
+ await fs.writeFile(sourcePath, sourceContent);
179
+ const imgStart = sourceContent.indexOf("img object-cover");
180
+ const registry: EditableRegistry = {
181
+ version: 1,
182
+ generatedAt: new Date().toISOString(),
183
+ elements: {
184
+ "test.tsx:15": {
185
+ file: "test.tsx",
186
+ type: "className",
187
+ startOffset: 16,
188
+ endOffset: 36,
189
+ originalContent: "wrap overflow-hidden",
190
+ context: { parentTag: "div" },
191
+ },
192
+ [`test.tsx:${imgStart - 1}`]: {
193
+ file: "test.tsx",
194
+ type: "className",
195
+ startOffset: imgStart,
196
+ endOffset: imgStart + "img object-cover".length,
197
+ originalContent: "img object-cover",
198
+ context: { parentTag: "img" },
199
+ },
200
+ },
201
+ };
202
+ await fs.writeFile(registryPath, JSON.stringify(registry));
203
+
204
+ const result = await api.editClassName({
205
+ action: "editClassName",
206
+ id: "test.tsx:40", // the image's id before the rebuild
207
+ className: "img object-cover object-[50%_30%]",
208
+ previousClassName: "img object-cover",
209
+ });
210
+
211
+ expect(result.success).toBe(true);
212
+ const updated = await fs.readFile(sourcePath, "utf-8");
213
+ expect(updated).toBe(
214
+ `<div className="wrap overflow-hidden"><img className="img object-cover object-[50%_30%]" /></div>`,
215
+ );
216
+ });
217
+
218
+ test("falls back to a source search when neither the id nor the registry know the className", async () => {
219
+ const sourceContent = `<img className="img object-cover" />`;
220
+ const sourcePath = path.join(tempDir, "test.tsx");
221
+ await fs.writeFile(sourcePath, sourceContent);
222
+ const registry: EditableRegistry = { version: 1, generatedAt: new Date().toISOString(), elements: {} };
223
+ await fs.writeFile(registryPath, JSON.stringify(registry));
224
+
225
+ const result = await api.editClassName({
226
+ action: "editClassName",
227
+ id: "test.tsx:15",
228
+ className: "img object-contain",
229
+ previousClassName: "img object-cover",
230
+ });
231
+
232
+ expect(result.success).toBe(true);
233
+ expect(await fs.readFile(sourcePath, "utf-8")).toBe(`<img className="img object-contain" />`);
234
+ // Registered under the preview's id so the next edit resolves directly.
235
+ expect(api.getRegistry()?.elements["test.tsx:15"]?.originalContent).toBe("img object-contain");
236
+ });
237
+
238
+ test("keeps failing on a stale id without previousClassName", async () => {
239
+ await fs.writeFile(path.join(tempDir, "test.tsx"), `<img className="img" />`);
240
+ const registry: EditableRegistry = { version: 1, generatedAt: new Date().toISOString(), elements: {} };
241
+ await fs.writeFile(registryPath, JSON.stringify(registry));
242
+
243
+ const result = await api.editClassName({ action: "editClassName", id: "test.tsx:15", className: "x" });
244
+ expect(result.success).toBe(false);
245
+ expect(result.error).toContain("not found in registry");
246
+ });
247
+
173
248
  test("should fail if element is not className type", async () => {
174
249
  const registry: EditableRegistry = {
175
250
  version: 1,
@@ -1569,6 +1569,27 @@ describe("upstart-editor-vite-plugin", () => {
1569
1569
  expect(result).toContain('data-upstart-editable-text="true"');
1570
1570
  });
1571
1571
 
1572
+ test("wraps a <Trans> in its own editable <span> when an icon sibling would be destroyed", () => {
1573
+ const code = `
1574
+ import { Trans } from "react-i18next";
1575
+ import { Icon } from "@iconify/react";
1576
+ export default function Badge() {
1577
+ return <div className="badge"><Icon icon="lucide:sparkles" /><Trans i18nKey="nav.badge" /></div>;
1578
+ }
1579
+ `;
1580
+ const result = transform(code) ?? "";
1581
+ // The container keeps its hash but is not editable itself…
1582
+ const container = result.match(/<div[^>]*className="badge"[^>]*>/)?.[0] ?? "";
1583
+ expect(container).toContain("data-upstart-hash=");
1584
+ expect(container).not.toContain("data-upstart-editable-text");
1585
+ // …the <Trans> is wrapped in an editable span carrying the i18n attributes.
1586
+ expect(result).toMatch(
1587
+ /<span data-upstart-hash="[a-f0-9]+" data-upstart-editable-text="true" data-upstart-editable-text-mode="plain" data-upstart-i18n="translation:nav\.badge"><Trans i18nKey="nav\.badge" \/><\/span>/,
1588
+ );
1589
+ // The icon is untouched.
1590
+ expect(result).toMatch(/<Icon [^>]*icon="lucide:sparkles"[^>]*\/>/);
1591
+ });
1592
+
1572
1593
  test("should promote <Trans> with custom namespace to parent", () => {
1573
1594
  const code = `
1574
1595
  import { Trans } from "react-i18next";
@@ -352,6 +352,13 @@ export const payloadEditClassName = z.object({
352
352
  action: z.literal("editClassName"),
353
353
  id: z.string().min(1),
354
354
  className: z.string(),
355
+ /**
356
+ * className currently rendered in the preview for this element. Registry ids are
357
+ * `file:offset`, so an earlier edit in the same file followed by a rebuild renames the ids
358
+ * while the preview (not reloaded) keeps the old ones: this lets the server find the element
359
+ * again by its content instead of failing on the stale id.
360
+ */
361
+ previousClassName: z.string().optional(),
355
362
  });
356
363
 
357
364
  export type PayloadEditClassName = z.infer<typeof payloadEditClassName>;
@@ -654,7 +661,7 @@ export class UpstartEditorAPI {
654
661
  if (!parsed.success) {
655
662
  return { success: false, error: `Invalid payload: ${parsed.error.message}` };
656
663
  }
657
- const { id, className: newClassName } = parsed.data;
664
+ const { id, className: newClassName, previousClassName } = parsed.data;
658
665
  if (!this.registry) {
659
666
  try {
660
667
  await this.loadRegistry();
@@ -663,16 +670,75 @@ export class UpstartEditorAPI {
663
670
  }
664
671
  }
665
672
 
666
- const entry = this.registry!.elements[id];
667
- if (!entry) {
668
- return { success: false, error: `Element ${id} not found in registry` };
669
- }
673
+ const resolved = await this.resolveClassNameEntry(id, previousClassName);
674
+ if ("error" in resolved) return { success: false, error: resolved.error };
675
+ return this.applyEdit(resolved.id, resolved.entry, newClassName);
676
+ }
670
677
 
671
- if (entry.type !== "className") {
672
- return { success: false, error: `Element ${id} is not a className element (type: ${entry.type})` };
678
+ /**
679
+ * Find the registry entry to edit for a className write. The id is authoritative when its
680
+ * entry still holds the className the preview shows. Otherwise the id is stale — an earlier
681
+ * edit in the same file shifted the offsets and a rebuild regenerated the registry with new
682
+ * ids, while the preview kept the old ones — and the element is looked up by its current
683
+ * className: first among the registry entries of the file (nearest to the old offset), then
684
+ * by a raw search of the source. Without `previousClassName` the old behaviour applies.
685
+ */
686
+ private async resolveClassNameEntry(
687
+ id: string,
688
+ previousClassName: string | undefined,
689
+ ): Promise<{ id: string; entry: EditableEntry } | { error: string }> {
690
+ const elements = this.registry!.elements;
691
+ const entry = elements[id];
692
+ if (entry && entry.type !== "className") {
693
+ return { error: `Element ${id} is not a className element (type: ${entry.type})` };
694
+ }
695
+ if (previousClassName === undefined || (entry && entry.originalContent === previousClassName)) {
696
+ return entry ? { id, entry } : { error: `Element ${id} not found in registry` };
697
+ }
698
+
699
+ const sep = id.lastIndexOf(":");
700
+ const file = sep === -1 ? (entry?.file ?? "") : id.slice(0, sep);
701
+ const wantedOffset = sep === -1 ? 0 : Number(id.slice(sep + 1)) || 0;
702
+ const byDistance = (a: number, b: number) => Math.abs(a - wantedOffset) - Math.abs(b - wantedOffset);
703
+
704
+ // Same className in the (regenerated) registry, nearest to where the element used to be.
705
+ const candidates = Object.entries(elements)
706
+ .filter(([, e]) => e.file === file && e.type === "className" && e.originalContent === previousClassName)
707
+ .sort(([, a], [, b]) => byDistance(a.startOffset, b.startOffset));
708
+ if (candidates[0]) return { id: candidates[0][0], entry: candidates[0][1] };
709
+
710
+ // Registry stale as well: look for the literal in the source itself.
711
+ if (file && previousClassName !== "") {
712
+ try {
713
+ const code = await fs.readFile(path.join(this.projectRoot, file), "utf-8");
714
+ const starts: number[] = [];
715
+ for (let i = code.indexOf(previousClassName); i !== -1; i = code.indexOf(previousClassName, i + 1)) {
716
+ const before = code[i - 1];
717
+ const after = code[i + previousClassName.length];
718
+ if ((before === '"' || before === "'" || before === "`") && after === before) starts.push(i);
719
+ }
720
+ starts.sort(byDistance);
721
+ if (starts[0] !== undefined) {
722
+ const found: EditableEntry = {
723
+ file,
724
+ type: "className",
725
+ startOffset: starts[0],
726
+ endOffset: starts[0] + previousClassName.length,
727
+ originalContent: previousClassName,
728
+ context: entry?.context ?? { parentTag: "unknown" },
729
+ };
730
+ // Registered under the id the preview uses so follow-up edits resolve directly.
731
+ elements[id] = found;
732
+ return { id, entry: found };
733
+ }
734
+ } catch {
735
+ // Unreadable file: fall through to the registry entry (if any).
736
+ }
673
737
  }
674
738
 
675
- return this.applyEdit(id, entry, newClassName);
739
+ // The preview's className may include runtime-added classes: trust the entry, which
740
+ // applyEdit still validates against the file content.
741
+ return entry ? { id, entry } : { error: `Element ${id} not found in registry` };
676
742
  }
677
743
 
678
744
  /**
@@ -330,7 +330,50 @@ export function transformWithOxc(code: string, filePath: string) {
330
330
  ? buildI18nAttributeValue(jsxNode, allI18nKeys, state.code, state, state.constants)
331
331
  : null;
332
332
 
333
- if (hasI18n && i18nAttrValue && (!isMixed || i18nAffixes)) {
333
+ // A single <Trans> next to element siblings without text (an <Icon>, an <svg>…): the
334
+ // plain editor replaces the whole content of the editable element, so making the
335
+ // container editable would destroy the icon, and React would later fight over it
336
+ // (Iconify swaps its placeholder <span> for the <svg> once the icon data is loaded).
337
+ // Wrap the <Trans> in its own editable <span> instead; the container stays untouched.
338
+ const directTransNodes = jsxNode.children.filter(
339
+ (c) =>
340
+ c.type === "JSXElement" && detectTransComponent(c as JSXElement, state.code, state.constants),
341
+ ) as JSXElement[];
342
+ const elementSiblings = jsxNode.children.filter(
343
+ (c) =>
344
+ c.type === "JSXElement" && !detectTransComponent(c as JSXElement, state.code, state.constants),
345
+ );
346
+ const transToWrap =
347
+ hasI18n &&
348
+ i18nAttrValue &&
349
+ !isMixed &&
350
+ transChildren.length === 1 &&
351
+ tCallChildren.length === 0 &&
352
+ directTransNodes.length === 1 &&
353
+ elementSiblings.length > 0 &&
354
+ hasRange(directTransNodes[0])
355
+ ? directTransNodes[0]
356
+ : null;
357
+
358
+ if (transToWrap) {
359
+ const wrapperHash = hashContent(state.code.slice(transToWrap.start, transToWrap.end));
360
+ const loopSuffixes = state.loopStack.map((l) => (l.indexName ? `\${${l.indexName}}` : "0"));
361
+ const wrapperAttrs = [
362
+ loopSuffixes.length > 0
363
+ ? `data-upstart-hash={\`${wrapperHash}-${loopSuffixes.join("-")}\`}`
364
+ : `data-upstart-hash="${wrapperHash}"`,
365
+ 'data-upstart-editable-text="true"',
366
+ 'data-upstart-editable-text-mode="plain"',
367
+ `data-upstart-i18n=${i18nAttrValue}`,
368
+ ];
369
+ const wrapperValueKeys = [...new Set(allI18nKeys.flatMap((t) => t.valueKeys ?? []))];
370
+ if (wrapperValueKeys.length > 0) {
371
+ wrapperAttrs.push(`data-i18n-values="${wrapperValueKeys.join(",")}"`);
372
+ }
373
+ state.s.appendLeft(transToWrap.start, `<span ${wrapperAttrs.join(" ")}>`);
374
+ state.s.appendRight(transToWrap.end, "</span>");
375
+ modified = true;
376
+ } else if (hasI18n && i18nAttrValue && (!isMixed || i18nAffixes)) {
334
377
  attributes.push('data-upstart-editable-text="true"');
335
378
  attributes.push('data-upstart-editable-text-mode="plain"');
336
379
 
@@ -1,5 +1,6 @@
1
1
  import { getCurrentMode } from "./state.js";
2
2
  import { sendToParent } from "./utils.js";
3
+ import { getLabels } from "./labels.js";
3
4
 
4
5
  // Editor-only controls for inline-array .map() lists. Add/delete are staged LOCALLY
5
6
  // (no server round-trip per action): "×" marks an item for removal, "+" inserts an
@@ -7,7 +8,6 @@ import { sendToParent } from "./utils.js";
7
8
  // via a single `arraySet` edit + rebuild. Per-item text editing stays live; arraySet
8
9
  // reads the current item texts from the DOM so live edits are preserved.
9
10
 
10
- const DEFAULT_NEW_ITEM = "New item";
11
11
  const DRAFT_ATTR = "data-upstart-draft-item";
12
12
 
13
13
  // Layout of the "+"/"✓" stack placed to the right of a list.
@@ -299,7 +299,7 @@ function makeItemDeleteButton(
299
299
  ): HTMLButtonElement {
300
300
  const btn = baseButton(rect.top + window.scrollY - 8, rect.right + window.scrollX - 8, 16, "#ef4444");
301
301
  setIcon(btn, marked ? "restore" : "cross", 10);
302
- btn.title = marked ? "Keep item" : "Remove item";
302
+ btn.title = marked ? getLabels().keepItem : getLabels().removeItem;
303
303
  btn.addEventListener("click", (e) => {
304
304
  e.preventDefault();
305
305
  e.stopPropagation();
@@ -316,7 +316,7 @@ function makeItemDeleteButton(
316
316
  function makeDraftRemoveButton(pill: HTMLElement, rect: DOMRect): HTMLButtonElement {
317
317
  const btn = baseButton(rect.top + window.scrollY - 8, rect.right + window.scrollX - 8, 16, "#ef4444");
318
318
  setIcon(btn, "cross", 10);
319
- btn.title = "Remove item";
319
+ btn.title = getLabels().removeItem;
320
320
  btn.addEventListener("click", (e) => {
321
321
  e.preventDefault();
322
322
  e.stopPropagation();
@@ -329,7 +329,7 @@ function makeDraftRemoveButton(pill: HTMLElement, rect: DOMRect): HTMLButtonElem
329
329
  function makeAddButton(arrayId: string, top: number, left: number): HTMLButtonElement {
330
330
  const btn = baseButton(top, left, 22, "#7270c6");
331
331
  setIcon(btn, "plus", 14);
332
- btn.title = "Add item";
332
+ btn.title = getLabels().addItem;
333
333
  btn.addEventListener("click", (e) => {
334
334
  e.preventDefault();
335
335
  e.stopPropagation();
@@ -341,7 +341,7 @@ function makeAddButton(arrayId: string, top: number, left: number): HTMLButtonEl
341
341
  function makeApplyButton(arrayId: string, top: number, left: number): HTMLButtonElement {
342
342
  const btn = baseButton(top, left, 22, "#16a34a");
343
343
  setIcon(btn, "check", 13);
344
- btn.title = "Apply changes";
344
+ btn.title = getLabels().applyChanges;
345
345
  btn.addEventListener("click", (e) => {
346
346
  e.preventDefault();
347
347
  e.stopPropagation();
@@ -357,7 +357,7 @@ function makeDragHandle(arrayId: string, node: HTMLElement, rect: DOMRect): HTML
357
357
  const left = rect.left + window.scrollX + rect.width / 2 - 8;
358
358
  const btn = baseButton(top, left, 16, "#6b7280");
359
359
  setIcon(btn, "grip", 12);
360
- btn.title = "Drag to reorder";
360
+ btn.title = getLabels().dragToReorder;
361
361
  btn.style.cursor = "grab";
362
362
  btn.addEventListener("pointerdown", (e) => {
363
363
  e.preventDefault();
@@ -430,7 +430,7 @@ function addDraftPill(arrayId: string): void {
430
430
  pill.removeAttribute("style");
431
431
  pill.setAttribute(DRAFT_ATTR, arrayId);
432
432
  pill.setAttribute("contenteditable", "true");
433
- pill.textContent = DEFAULT_NEW_ITEM;
433
+ pill.textContent = getLabels().newItem;
434
434
 
435
435
  // Insert after the current last pill of this array (drafts go at the end).
436
436
  const last = draftPillsFor(arrayId).pop() ?? originalItemsFor(arrayId).pop();