@upstart.gg/vite-plugins 0.1.84 → 0.1.85
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/upstart-editor-api.d.ts +58 -1
- package/dist/upstart-editor-api.d.ts.map +1 -1
- package/dist/upstart-editor-api.js +224 -1
- package/dist/upstart-editor-api.js.map +1 -1
- package/dist/vite-plugin-upstart-attrs.d.ts +1 -1
- package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-attrs.js +65 -2
- package/dist/vite-plugin-upstart-attrs.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/array-controls.js +2 -24
- package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +7 -1
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/controls-ui.js +33 -0
- package/dist/vite-plugin-upstart-editor/runtime/controls-ui.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.js +23 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts +5 -0
- package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/labels.js +5 -0
- package/dist/vite-plugin-upstart-editor/runtime/labels.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/move-controls.js +434 -0
- package/dist/vite-plugin-upstart-editor/runtime/move-controls.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +6 -3
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/tests/move-controls.test.ts +86 -0
- package/src/tests/upstart-editor-api-move-element.test.ts +220 -0
- package/src/tests/vite-plugin-upstart-attrs.test.ts +165 -4
- package/src/upstart-editor-api.ts +263 -0
- package/src/vite-plugin-upstart-attrs.ts +93 -2
- package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +2 -40
- package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +17 -3
- package/src/vite-plugin-upstart-editor/runtime/controls-ui.ts +48 -0
- package/src/vite-plugin-upstart-editor/runtime/index.ts +31 -0
- package/src/vite-plugin-upstart-editor/runtime/labels.ts +10 -0
- package/src/vite-plugin-upstart-editor/runtime/move-controls.ts +536 -0
- package/src/vite-plugin-upstart-editor/runtime/types.ts +7 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { UpstartEditorAPI, mapOffset, moveRange } from "../upstart-editor-api";
|
|
6
|
+
import { transformWithOxc, getRegistry, clearRegistry } from "../vite-plugin-upstart-attrs";
|
|
7
|
+
|
|
8
|
+
const FILE = "app/components/Hero.tsx";
|
|
9
|
+
|
|
10
|
+
const HERO = `export function Hero() {
|
|
11
|
+
return (
|
|
12
|
+
<section className="hero">
|
|
13
|
+
<h1>Welcome</h1>
|
|
14
|
+
<p>Some text</p>
|
|
15
|
+
<div className="media">
|
|
16
|
+
<img src="/images/hero.webp" alt="" />
|
|
17
|
+
<span>Caption</span>
|
|
18
|
+
</div>
|
|
19
|
+
</section>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
type Position = "before" | "after";
|
|
25
|
+
|
|
26
|
+
describe("moveElements", () => {
|
|
27
|
+
let tempDir: string;
|
|
28
|
+
let api: UpstartEditorAPI;
|
|
29
|
+
|
|
30
|
+
// Run the attrs plugin on `code`, register it in the API and write the file; returns the
|
|
31
|
+
// movable element ids keyed by tag name.
|
|
32
|
+
async function setup(code: string): Promise<Record<string, string>> {
|
|
33
|
+
clearRegistry();
|
|
34
|
+
transformWithOxc(code, FILE);
|
|
35
|
+
const ids: Record<string, string> = {};
|
|
36
|
+
for (const [id, entry] of Object.entries(getRegistry())) {
|
|
37
|
+
if (entry.type !== "element") continue;
|
|
38
|
+
const tag = entry.originalContent.match(/^<([a-z0-9]+)/)?.[1];
|
|
39
|
+
if (tag) ids[tag] = id;
|
|
40
|
+
}
|
|
41
|
+
await fs.mkdir(path.join(tempDir, "app", "components"), { recursive: true });
|
|
42
|
+
await fs.writeFile(path.join(tempDir, FILE), code);
|
|
43
|
+
api.setRegistry({ version: 1, generatedAt: "", elements: getRegistry() });
|
|
44
|
+
return ids;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const read = () => fs.readFile(path.join(tempDir, FILE), "utf-8");
|
|
48
|
+
const move = (...moves: [id: string, targetId: string, position: Position][]) =>
|
|
49
|
+
api.moveElements({
|
|
50
|
+
action: "moveElements",
|
|
51
|
+
moves: moves.map(([id, targetId, position]) => ({ id, targetId, position })),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "upstart-move-"));
|
|
56
|
+
api = new UpstartEditorAPI(tempDir, path.join(tempDir, "registry.json"));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
clearRegistry();
|
|
61
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("moves an element after a sibling", async () => {
|
|
65
|
+
const ids = await setup(HERO);
|
|
66
|
+
const result = await move([ids.h1, ids.p, "after"]);
|
|
67
|
+
expect(result).toEqual({ success: true, filePaths: [path.join(tempDir, FILE)] });
|
|
68
|
+
expect(await read()).toBe(
|
|
69
|
+
HERO.replace("<h1>Welcome</h1>\n <p>Some text</p>", "<p>Some text</p>\n <h1>Welcome</h1>"),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("moves an element before a sibling", async () => {
|
|
74
|
+
const ids = await setup(HERO);
|
|
75
|
+
expect((await move([ids.p, ids.h1, "before"])).success).toBe(true);
|
|
76
|
+
expect(await read()).toContain("<p>Some text</p>\n <h1>Welcome</h1>");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("re-indents an element moved to another nesting level", async () => {
|
|
80
|
+
const ids = await setup(HERO);
|
|
81
|
+
expect((await move([ids.img, ids.h1, "before"])).success).toBe(true);
|
|
82
|
+
const code = await read();
|
|
83
|
+
expect(code).toContain(
|
|
84
|
+
'<section className="hero">\n <img src="/images/hero.webp" alt="" />\n <h1>',
|
|
85
|
+
);
|
|
86
|
+
expect(code).toContain('<div className="media">\n <span>Caption</span>\n </div>');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("applies several moves in order, tracking the elements an earlier move shifted", async () => {
|
|
90
|
+
const ids = await setup(HERO);
|
|
91
|
+
const result = await move(
|
|
92
|
+
[ids.img, ids.h1, "before"],
|
|
93
|
+
[ids.h1, ids.p, "after"],
|
|
94
|
+
[ids.img, ids.h1, "after"],
|
|
95
|
+
);
|
|
96
|
+
expect(result.success).toBe(true);
|
|
97
|
+
expect(await read()).toContain(
|
|
98
|
+
'<section className="hero">\n <p>Some text</p>\n <h1>Welcome</h1>\n <img src="/images/hero.webp" alt="" />\n <div className="media">',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a move undone by a later one leaves the file unchanged and reports no file", async () => {
|
|
103
|
+
const ids = await setup(HERO);
|
|
104
|
+
const result = await move([ids.h1, ids.p, "after"], [ids.h1, ids.p, "before"]);
|
|
105
|
+
expect(result).toEqual({ success: true, filePaths: [] });
|
|
106
|
+
expect(await read()).toBe(HERO);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("moving an element next to itself leaves the file unchanged", async () => {
|
|
110
|
+
const ids = await setup(HERO);
|
|
111
|
+
expect(await move([ids.p, ids.h1, "after"])).toEqual({ success: true, filePaths: [] });
|
|
112
|
+
expect(await read()).toBe(HERO);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("finds an element whose offsets shifted after an earlier edit", async () => {
|
|
116
|
+
const ids = await setup(HERO);
|
|
117
|
+
await fs.writeFile(path.join(tempDir, FILE), `// edited\n${HERO}`);
|
|
118
|
+
expect((await move([ids.h1, ids.p, "after"])).success).toBe(true);
|
|
119
|
+
expect(await read()).toContain("<p>Some text</p>\n <h1>Welcome</h1>");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("refuses elements from different files and writes nothing", async () => {
|
|
123
|
+
const ids = await setup(HERO);
|
|
124
|
+
const elements = getRegistry();
|
|
125
|
+
elements[ids.p] = { ...elements[ids.p], file: "app/components/Other.tsx" };
|
|
126
|
+
api.setRegistry({ version: 1, generatedAt: "", elements });
|
|
127
|
+
expect((await move([ids.h1, ids.img, "after"], [ids.h1, ids.p, "after"])).success).toBe(false);
|
|
128
|
+
expect(await read()).toBe(HERO);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("refuses ids that are not movable elements", async () => {
|
|
132
|
+
const ids = await setup(HERO);
|
|
133
|
+
const classNameId = Object.entries(getRegistry()).find(([, e]) => e.type === "className")![0];
|
|
134
|
+
expect((await move([classNameId, ids.h1, "after"])).success).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("refuses to move an element into itself", async () => {
|
|
138
|
+
const code = `export function A() {
|
|
139
|
+
return (
|
|
140
|
+
<div>
|
|
141
|
+
<p>
|
|
142
|
+
Hello <strong>world</strong>
|
|
143
|
+
</p>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
`;
|
|
148
|
+
await fs.mkdir(path.join(tempDir, "app", "components"), { recursive: true });
|
|
149
|
+
await fs.writeFile(path.join(tempDir, FILE), code);
|
|
150
|
+
const outer = code.slice(code.indexOf("<p>"), code.indexOf("</p>") + 4);
|
|
151
|
+
api.setRegistry({
|
|
152
|
+
version: 1,
|
|
153
|
+
generatedAt: "",
|
|
154
|
+
elements: {
|
|
155
|
+
outer: {
|
|
156
|
+
file: FILE,
|
|
157
|
+
type: "element",
|
|
158
|
+
startOffset: code.indexOf("<p>"),
|
|
159
|
+
endOffset: code.indexOf("<p>") + outer.length,
|
|
160
|
+
originalContent: outer,
|
|
161
|
+
context: { parentTag: "div" },
|
|
162
|
+
},
|
|
163
|
+
inner: {
|
|
164
|
+
file: FILE,
|
|
165
|
+
type: "element",
|
|
166
|
+
startOffset: code.indexOf("<strong>"),
|
|
167
|
+
endOffset: code.indexOf("</strong>") + 9,
|
|
168
|
+
originalContent: "<strong>world</strong>",
|
|
169
|
+
context: { parentTag: "p" },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
expect((await move(["outer", "inner", "after"])).success).toBe(false);
|
|
174
|
+
expect(await read()).toBe(code);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("moveRange", () => {
|
|
179
|
+
const range = (code: string, part: string) => {
|
|
180
|
+
const start = code.indexOf(part);
|
|
181
|
+
return { start, end: start + part.length };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
test("moves inline elements as-is", () => {
|
|
185
|
+
const code = "<p><a>1</a> <b>2</b></p>";
|
|
186
|
+
const edit = moveRange(code, range(code, "<b>2</b>"), range(code, "<a>1</a>"), "before");
|
|
187
|
+
expect(edit.code).toBe("<p><b>2</b><a>1</a> </p>");
|
|
188
|
+
expect(edit.code.slice(edit.moved.start, edit.moved.end)).toBe("<b>2</b>");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("moves an element after the last line of the file", () => {
|
|
192
|
+
const code = " <a />\n <b />";
|
|
193
|
+
const edit = moveRange(code, range(code, "<a />"), range(code, "<b />"), "after");
|
|
194
|
+
expect(edit.code).toBe(" <b />\n <a />");
|
|
195
|
+
expect(edit.code.slice(edit.moved.start, edit.moved.end)).toBe("<a />");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("moves a multi-line element and keeps its inner indentation", () => {
|
|
199
|
+
const code = " <ul>\n <li>x</li>\n </ul>\n <p>y</p>\n";
|
|
200
|
+
const edit = moveRange(
|
|
201
|
+
code,
|
|
202
|
+
range(code, "<ul>\n <li>x</li>\n </ul>"),
|
|
203
|
+
range(code, "<p>y</p>"),
|
|
204
|
+
"after",
|
|
205
|
+
);
|
|
206
|
+
expect(edit.code).toBe(" <p>y</p>\n <ul>\n <li>x</li>\n </ul>\n");
|
|
207
|
+
expect(edit.code.slice(edit.moved.start, edit.moved.end)).toBe("<ul>\n <li>x</li>\n </ul>");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("mapOffset follows the elements around the move", () => {
|
|
211
|
+
const code = "<a />\n<b />\n<c />\n<d />\n";
|
|
212
|
+
const edit = moveRange(code, range(code, "<a />"), range(code, "<c />"), "after");
|
|
213
|
+
expect(edit.code).toBe("<b />\n<c />\n<a />\n<d />\n");
|
|
214
|
+
for (const tag of ["<b />", "<c />", "<d />"]) {
|
|
215
|
+
const r = range(code, tag);
|
|
216
|
+
const start = mapOffset(edit, r.start, false);
|
|
217
|
+
expect(edit.code.slice(start, mapOffset(edit, r.end, true))).toBe(tag);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -1581,10 +1581,10 @@ describe("upstart-editor-vite-plugin", () => {
|
|
|
1581
1581
|
const registry = getRegistry();
|
|
1582
1582
|
const entries = Object.values(registry);
|
|
1583
1583
|
|
|
1584
|
-
// 2 text (spans) + 2 className (spans); the outer div is
|
|
1585
|
-
// its <span> children have attributes (className), which
|
|
1586
|
-
// simple inline formatting wrappers.
|
|
1587
|
-
expect(entries.length).toBe(
|
|
1584
|
+
// 2 text (spans) + 2 className (spans) + 2 movable elements (spans); the outer div is
|
|
1585
|
+
// NOT almost-leaf because its <span> children have attributes (className), which
|
|
1586
|
+
// disqualifies them as simple inline formatting wrappers.
|
|
1587
|
+
expect(entries.length).toBe(6);
|
|
1588
1588
|
|
|
1589
1589
|
const textEntries = entries.filter((e) => e.type === "text");
|
|
1590
1590
|
const classEntries = entries.filter((e) => e.type === "className");
|
|
@@ -1596,6 +1596,167 @@ describe("upstart-editor-vite-plugin", () => {
|
|
|
1596
1596
|
});
|
|
1597
1597
|
});
|
|
1598
1598
|
|
|
1599
|
+
describe("Movable elements (drag & drop)", () => {
|
|
1600
|
+
const moveIdOf = (out: string, text: string) =>
|
|
1601
|
+
out.match(new RegExp(`data-upstart-move-id="([^"]+)"[^>]*>${text}<`))?.[1];
|
|
1602
|
+
|
|
1603
|
+
test("marks text and image elements that are direct JSX children", () => {
|
|
1604
|
+
const code = `export function Hero() {
|
|
1605
|
+
return (
|
|
1606
|
+
<section>
|
|
1607
|
+
<h1>Title</h1>
|
|
1608
|
+
<p>Body</p>
|
|
1609
|
+
<img src="/a.webp" alt="" />
|
|
1610
|
+
</section>
|
|
1611
|
+
);
|
|
1612
|
+
}`;
|
|
1613
|
+
const out = transform(code)!;
|
|
1614
|
+
expect(out.match(/data-upstart-move-id=/g)?.length).toBe(3);
|
|
1615
|
+
// The <section> root is not a text/image element and has no JSX parent.
|
|
1616
|
+
expect(out).not.toMatch(/<section[^>]*data-upstart-move-id/);
|
|
1617
|
+
const id = moveIdOf(out, "Title")!;
|
|
1618
|
+
const entry = getRegistry()[id];
|
|
1619
|
+
expect(entry.type).toBe("element");
|
|
1620
|
+
expect(entry.originalContent).toBe("<h1>Title</h1>");
|
|
1621
|
+
expect(code.slice(entry.startOffset, entry.endOffset)).toBe("<h1>Title</h1>");
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
test("does not mark elements in loops, conditionals or components", () => {
|
|
1625
|
+
const code = `export function List({ show }) {
|
|
1626
|
+
return (
|
|
1627
|
+
<div>
|
|
1628
|
+
{["a", "b"].map((x, i) => <span key={i}>{x}</span>)}
|
|
1629
|
+
{show && <p>Maybe</p>}
|
|
1630
|
+
{show ? <h2>Yes</h2> : <h2>No</h2>}
|
|
1631
|
+
<Title>Component</Title>
|
|
1632
|
+
</div>
|
|
1633
|
+
);
|
|
1634
|
+
}`;
|
|
1635
|
+
const out = transform(code)!;
|
|
1636
|
+
expect(out).not.toContain("data-upstart-move-id");
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
test("marks whole blocks, not only texts and images", () => {
|
|
1640
|
+
const code = `export function Page() {
|
|
1641
|
+
return (
|
|
1642
|
+
<main>
|
|
1643
|
+
<section className="a">
|
|
1644
|
+
<div className="card">
|
|
1645
|
+
<h2>Title</h2>
|
|
1646
|
+
<p>Text</p>
|
|
1647
|
+
</div>
|
|
1648
|
+
<a href="/more">More</a>
|
|
1649
|
+
</section>
|
|
1650
|
+
<ul>
|
|
1651
|
+
<li>One</li>
|
|
1652
|
+
<li>Two</li>
|
|
1653
|
+
</ul>
|
|
1654
|
+
</main>
|
|
1655
|
+
);
|
|
1656
|
+
}`;
|
|
1657
|
+
const out = transform(code)!;
|
|
1658
|
+
for (const tag of ["section", "div", "h2", "ul", "li"]) {
|
|
1659
|
+
expect(out).toMatch(new RegExp(`<${tag}[^>]*data-upstart-move-id=`));
|
|
1660
|
+
}
|
|
1661
|
+
expect(out).not.toMatch(/<main[^>]*data-upstart-move-id/);
|
|
1662
|
+
const sectionId = out.match(/<section[^>]*data-upstart-move-id="([^"]+)"/)![1];
|
|
1663
|
+
expect(getRegistry()[sectionId].originalContent).toMatch(/^<section className="a">[\s\S]*<\/section>$/);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
test("does not mark the only content of an HTML element: its parent moves instead", () => {
|
|
1667
|
+
const code = `export function Page() {
|
|
1668
|
+
return (
|
|
1669
|
+
<main>
|
|
1670
|
+
<section className="outer">
|
|
1671
|
+
<div className="wrapper">
|
|
1672
|
+
<div className="container">
|
|
1673
|
+
<h2>Title</h2>
|
|
1674
|
+
<p>Body</p>
|
|
1675
|
+
</div>
|
|
1676
|
+
</div>
|
|
1677
|
+
</section>
|
|
1678
|
+
<figure>
|
|
1679
|
+
{/* comment */}
|
|
1680
|
+
<img src="/a.webp" alt="" />
|
|
1681
|
+
</figure>
|
|
1682
|
+
<p>
|
|
1683
|
+
Hello <strong>you</strong>
|
|
1684
|
+
</p>
|
|
1685
|
+
<p>{name}<em>!</em></p>
|
|
1686
|
+
<Card>
|
|
1687
|
+
<h3>Inside a component</h3>
|
|
1688
|
+
</Card>
|
|
1689
|
+
</main>
|
|
1690
|
+
);
|
|
1691
|
+
}`;
|
|
1692
|
+
const out = transform(code)!;
|
|
1693
|
+
const movable = (tag: string) => new RegExp(`<${tag}[ >][^>]*data-upstart-move-id=`);
|
|
1694
|
+
// section > wrapper > container: only the section carries a handle for the chain.
|
|
1695
|
+
expect(out).toMatch(movable("section"));
|
|
1696
|
+
expect(out).not.toMatch(/className="wrapper"[^>]*data-upstart-move-id/);
|
|
1697
|
+
expect(out).not.toMatch(/className="container"[^>]*data-upstart-move-id/);
|
|
1698
|
+
// Siblings are not "only content".
|
|
1699
|
+
expect(out).toMatch(movable("h2"));
|
|
1700
|
+
expect(out).toMatch(movable("p"));
|
|
1701
|
+
// A comment does not count as content: the <img> is the figure's only content.
|
|
1702
|
+
expect(out).toMatch(movable("figure"));
|
|
1703
|
+
expect(out).not.toMatch(movable("img"));
|
|
1704
|
+
// Text or an expression next to the element keeps it movable.
|
|
1705
|
+
expect(out).toMatch(movable("strong"));
|
|
1706
|
+
expect(out).toMatch(movable("em"));
|
|
1707
|
+
// The child of a component stays movable.
|
|
1708
|
+
expect(out).toMatch(movable("h3"));
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
test("does not mark document structure, SVG internals, Trans slots or <br>", () => {
|
|
1712
|
+
const code = `export function Layout() {
|
|
1713
|
+
return (
|
|
1714
|
+
<html>
|
|
1715
|
+
<head>
|
|
1716
|
+
<meta charSet="utf-8" />
|
|
1717
|
+
<title>Site</title>
|
|
1718
|
+
</head>
|
|
1719
|
+
<body>
|
|
1720
|
+
<p>
|
|
1721
|
+
Hello<br />world
|
|
1722
|
+
</p>
|
|
1723
|
+
<svg viewBox="0 0 10 10">
|
|
1724
|
+
<path d="M0 0" />
|
|
1725
|
+
</svg>
|
|
1726
|
+
<span>
|
|
1727
|
+
<Trans i18nKey="x" components={{ b: <b /> }}>
|
|
1728
|
+
<b>bold</b>
|
|
1729
|
+
</Trans>
|
|
1730
|
+
</span>
|
|
1731
|
+
</body>
|
|
1732
|
+
</html>
|
|
1733
|
+
);
|
|
1734
|
+
}`;
|
|
1735
|
+
const out = transform(code)!;
|
|
1736
|
+
for (const tag of ["html", "head", "meta", "title", "body", "br", "path", "b"]) {
|
|
1737
|
+
expect(out).not.toMatch(new RegExp(`<${tag}[ /][^>]*data-upstart-move-id=`));
|
|
1738
|
+
}
|
|
1739
|
+
// The <p>, the <svg> itself and the <span> stay movable.
|
|
1740
|
+
for (const tag of ["p", "svg", "span"]) {
|
|
1741
|
+
expect(out).toMatch(new RegExp(`<${tag}[^>]*data-upstart-move-id=`));
|
|
1742
|
+
}
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
test("gives each component of a file its own scope", () => {
|
|
1746
|
+
const code = `export function A() {
|
|
1747
|
+
return <div><p>One</p><p>Uno</p></div>;
|
|
1748
|
+
}
|
|
1749
|
+
export function B() {
|
|
1750
|
+
return <div><p>Two</p><p>Dos</p></div>;
|
|
1751
|
+
}`;
|
|
1752
|
+
const out = transform(code)!;
|
|
1753
|
+
const scopes = [...new Set([...out.matchAll(/data-upstart-move-scope="([^"]+)"/g)].map((m) => m[1]))];
|
|
1754
|
+
expect(scopes).toHaveLength(2);
|
|
1755
|
+
// Scope = start offset of the enclosing function (after "export ").
|
|
1756
|
+
expect(scopes[0]).toBe("test.tsx:7");
|
|
1757
|
+
});
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1599
1760
|
describe("Integration Tests", () => {
|
|
1600
1761
|
test("should transform real-world component correctly", () => {
|
|
1601
1762
|
const code = `
|
|
@@ -341,6 +341,150 @@ function printMetaTag(fieldName: string, value: string): string {
|
|
|
341
341
|
return `<meta name="${fieldName}" ${attribute} />`;
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
interface SourceRange {
|
|
345
|
+
start: number;
|
|
346
|
+
end: number;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A tracked element: its current range and the source it is expected to hold there. */
|
|
350
|
+
interface TrackedElement extends SourceRange {
|
|
351
|
+
content: string;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Current range of an element in `code`: `hint` when the source still matches there, otherwise
|
|
356
|
+
* the occurrence of its source nearest to `hint` (an earlier edit shifted it), and as a last
|
|
357
|
+
* resort a whitespace-insensitive match (a move re-indented it).
|
|
358
|
+
*/
|
|
359
|
+
function locateElement(code: string, hint: TrackedElement): TrackedElement | null {
|
|
360
|
+
const { start, end, content } = hint;
|
|
361
|
+
if (code.slice(start, end) === content) return hint;
|
|
362
|
+
if (content.trim() === "") return null;
|
|
363
|
+
const nearest = (a: number, b: number) => Math.abs(a - start) < Math.abs(b - start);
|
|
364
|
+
let best = -1;
|
|
365
|
+
for (let i = code.indexOf(content); i !== -1; i = code.indexOf(content, i + 1)) {
|
|
366
|
+
if (best === -1 || nearest(i, best)) best = i;
|
|
367
|
+
}
|
|
368
|
+
if (best !== -1) return { start: best, end: best + content.length, content };
|
|
369
|
+
|
|
370
|
+
const pattern = content
|
|
371
|
+
.trim()
|
|
372
|
+
.split(/\s+/)
|
|
373
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
374
|
+
.join("\\s+");
|
|
375
|
+
let found: TrackedElement | null = null;
|
|
376
|
+
for (const m of code.matchAll(new RegExp(pattern, "g"))) {
|
|
377
|
+
if (!found || nearest(m.index, found.start)) {
|
|
378
|
+
found = { start: m.index, end: m.index + m[0].length, content: m[0] };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return found;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The full lines a range occupies (from the line start to just after its trailing newline)
|
|
386
|
+
* when nothing else than whitespace shares those lines, plus the indentation of the first one.
|
|
387
|
+
*/
|
|
388
|
+
function ownLines(code: string, range: SourceRange): { start: number; end: number; indent: string } | null {
|
|
389
|
+
const lineStart = code.lastIndexOf("\n", range.start - 1) + 1;
|
|
390
|
+
const indent = code.slice(lineStart, range.start);
|
|
391
|
+
if (indent.trim() !== "") return null;
|
|
392
|
+
const newline = code.indexOf("\n", range.end);
|
|
393
|
+
const lineEnd = newline === -1 ? code.length : newline;
|
|
394
|
+
if (code.slice(range.end, lineEnd).trim() !== "") return null;
|
|
395
|
+
return { start: lineStart, end: newline === -1 ? code.length : newline + 1, indent };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The result of moving a range: the new code, plus what changed so the offsets of other
|
|
400
|
+
* elements can be mapped onto it (see `mapOffset`). Offsets are in the ORIGINAL code, except
|
|
401
|
+
* `moved`, which is the new range of the moved element.
|
|
402
|
+
*/
|
|
403
|
+
export interface MoveEdit {
|
|
404
|
+
code: string;
|
|
405
|
+
/** Removed source range. */
|
|
406
|
+
removeStart: number;
|
|
407
|
+
removeEnd: number;
|
|
408
|
+
/** Where the moved text was inserted, and its length. */
|
|
409
|
+
insertAt: number;
|
|
410
|
+
insertLength: number;
|
|
411
|
+
moved: SourceRange;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Move the `src` range before or after the `tgt` range (`tgt` must not be inside `src`).
|
|
416
|
+
* Elements alone on their lines are moved as whole lines, re-indented to the target's level;
|
|
417
|
+
* inline elements are moved as-is. Inserting at the far edge of the target keeps the target's
|
|
418
|
+
* own range untouched even when the source sits inside it (e.g. a <span> out of its <p>).
|
|
419
|
+
*/
|
|
420
|
+
export function moveRange(
|
|
421
|
+
code: string,
|
|
422
|
+
src: SourceRange,
|
|
423
|
+
tgt: SourceRange,
|
|
424
|
+
position: "before" | "after",
|
|
425
|
+
): MoveEdit {
|
|
426
|
+
const srcLines = ownLines(code, src);
|
|
427
|
+
const tgtLines = ownLines(code, tgt);
|
|
428
|
+
let removeStart = src.start;
|
|
429
|
+
let removeEnd = src.end;
|
|
430
|
+
let insertAt = position === "before" ? tgt.start : tgt.end;
|
|
431
|
+
let inserted = code.slice(src.start, src.end);
|
|
432
|
+
let elementOffset = 0;
|
|
433
|
+
let elementLength = inserted.length;
|
|
434
|
+
|
|
435
|
+
if (srcLines && tgtLines) {
|
|
436
|
+
// Re-indent every line of the element from its own indentation to the target's.
|
|
437
|
+
const body = inserted
|
|
438
|
+
.split("\n")
|
|
439
|
+
.map((line, i) => {
|
|
440
|
+
if (i === 0) return line;
|
|
441
|
+
return line.startsWith(srcLines.indent) ? tgtLines.indent + line.slice(srcLines.indent.length) : line;
|
|
442
|
+
})
|
|
443
|
+
.join("\n");
|
|
444
|
+
removeStart = srcLines.start;
|
|
445
|
+
removeEnd = srcLines.end;
|
|
446
|
+
elementOffset = tgtLines.indent.length;
|
|
447
|
+
elementLength = body.length;
|
|
448
|
+
if (position === "before") {
|
|
449
|
+
insertAt = tgtLines.start;
|
|
450
|
+
inserted = `${tgtLines.indent}${body}\n`;
|
|
451
|
+
} else {
|
|
452
|
+
insertAt = tgtLines.end;
|
|
453
|
+
const atEofWithoutNewline = tgtLines.end === code.length && !code.endsWith("\n");
|
|
454
|
+
inserted = atEofWithoutNewline ? `\n${tgtLines.indent}${body}` : `${tgtLines.indent}${body}\n`;
|
|
455
|
+
if (atEofWithoutNewline) elementOffset += 1;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const removed = removeEnd - removeStart;
|
|
460
|
+
const next =
|
|
461
|
+
insertAt <= removeStart
|
|
462
|
+
? code.slice(0, insertAt) + inserted + code.slice(insertAt, removeStart) + code.slice(removeEnd)
|
|
463
|
+
: code.slice(0, removeStart) + code.slice(removeEnd, insertAt) + inserted + code.slice(insertAt);
|
|
464
|
+
const insertedAtInNewCode = insertAt <= removeStart ? insertAt : insertAt - removed;
|
|
465
|
+
const movedStart = insertedAtInNewCode + elementOffset;
|
|
466
|
+
return {
|
|
467
|
+
code: next,
|
|
468
|
+
removeStart,
|
|
469
|
+
removeEnd,
|
|
470
|
+
insertAt,
|
|
471
|
+
insertLength: inserted.length,
|
|
472
|
+
moved: { start: movedStart, end: movedStart + elementLength },
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Where an offset of the original code (outside the removed range) lands after `edit`.
|
|
478
|
+
* A start sitting exactly at the insertion point is pushed after the inserted text, an end
|
|
479
|
+
* sitting there stays before it.
|
|
480
|
+
*/
|
|
481
|
+
export function mapOffset(edit: MoveEdit, offset: number, isEnd: boolean): number {
|
|
482
|
+
let mapped = offset;
|
|
483
|
+
if (offset >= edit.removeEnd) mapped -= edit.removeEnd - edit.removeStart;
|
|
484
|
+
if (isEnd ? offset > edit.insertAt : offset >= edit.insertAt) mapped += edit.insertLength;
|
|
485
|
+
return mapped;
|
|
486
|
+
}
|
|
487
|
+
|
|
344
488
|
/** Start of the line `offset` sits on, so a removed tag doesn't leave a blank line behind. */
|
|
345
489
|
function lineRangeStart(code: string, offset: number): number {
|
|
346
490
|
const lineStart = code.lastIndexOf("\n", offset);
|
|
@@ -422,6 +566,27 @@ export const payloadArraySet = z.object({
|
|
|
422
566
|
|
|
423
567
|
export type PayloadArraySet = z.infer<typeof payloadArraySet>;
|
|
424
568
|
|
|
569
|
+
// Move JSX elements (registry entries of type "element") before or after other elements of the
|
|
570
|
+
// same file — the editor's drag & drop. Moves are staged in the preview and applied together,
|
|
571
|
+
// in order, when the user confirms. Ids are "<relativeFile>:<elementStart>".
|
|
572
|
+
export const payloadMoveElements = z.object({
|
|
573
|
+
action: z.literal("moveElements"),
|
|
574
|
+
moves: z
|
|
575
|
+
.array(
|
|
576
|
+
z.object({
|
|
577
|
+
id: z.string().min(1),
|
|
578
|
+
targetId: z.string().min(1),
|
|
579
|
+
position: z.enum(["before", "after"]),
|
|
580
|
+
}),
|
|
581
|
+
)
|
|
582
|
+
.min(1)
|
|
583
|
+
.max(200),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
export type PayloadMoveElements = z.infer<typeof payloadMoveElements>;
|
|
587
|
+
|
|
588
|
+
export type MoveElementsResult = { success: true; filePaths: string[] } | { success: false; error: string };
|
|
589
|
+
|
|
425
590
|
// React-router route id, as reported by the client runtime (e.g. "routes/_layout._index",
|
|
426
591
|
// "root"). Resolved to `<projectRoot>/app/<routeId>.<ext>` — see `resolveRouteFile`.
|
|
427
592
|
const routeIdSchema = z
|
|
@@ -938,6 +1103,104 @@ export class UpstartEditorAPI {
|
|
|
938
1103
|
}
|
|
939
1104
|
}
|
|
940
1105
|
|
|
1106
|
+
/**
|
|
1107
|
+
* Apply a batch of element moves (editor drag & drop), in order. Every element involved is
|
|
1108
|
+
* tracked across the moves, so a later move finds elements an earlier one shifted. Files are
|
|
1109
|
+
* only written when every move succeeded and every result still parses.
|
|
1110
|
+
*/
|
|
1111
|
+
async moveElements(params: PayloadMoveElements): Promise<MoveElementsResult> {
|
|
1112
|
+
const parsed = payloadMoveElements.safeParse(params);
|
|
1113
|
+
if (!parsed.success) {
|
|
1114
|
+
return { success: false, error: `Invalid payload: ${parsed.error.message}` };
|
|
1115
|
+
}
|
|
1116
|
+
if (!this.registry) {
|
|
1117
|
+
try {
|
|
1118
|
+
await this.loadRegistry();
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
return { success: false, error: `Failed to load registry: ${err}` };
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const elements = this.registry!.elements;
|
|
1124
|
+
|
|
1125
|
+
const codes = new Map<string, string>();
|
|
1126
|
+
const tracked = new Map<string, TrackedElement>();
|
|
1127
|
+
try {
|
|
1128
|
+
for (const { id, targetId, position } of parsed.data.moves) {
|
|
1129
|
+
if (id === targetId) return { success: false, error: "Cannot move an element onto itself" };
|
|
1130
|
+
const source = elements[id];
|
|
1131
|
+
const target = elements[targetId];
|
|
1132
|
+
if (source?.type !== "element")
|
|
1133
|
+
return { success: false, error: `Element ${id} not found in registry` };
|
|
1134
|
+
if (target?.type !== "element") {
|
|
1135
|
+
return { success: false, error: `Element ${targetId} not found in registry` };
|
|
1136
|
+
}
|
|
1137
|
+
if (source.file !== target.file) {
|
|
1138
|
+
return { success: false, error: "Elements can only be moved within the same file" };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const file = source.file;
|
|
1142
|
+
let code = codes.get(file);
|
|
1143
|
+
if (code === undefined) {
|
|
1144
|
+
code = await fs.readFile(path.join(this.projectRoot, file), "utf-8");
|
|
1145
|
+
codes.set(file, code);
|
|
1146
|
+
// Track every movable element of the file: any of them may be shifted by a move.
|
|
1147
|
+
for (const [elementId, entry] of Object.entries(elements)) {
|
|
1148
|
+
if (entry.type !== "element" || entry.file !== file) continue;
|
|
1149
|
+
tracked.set(elementId, {
|
|
1150
|
+
start: entry.startOffset,
|
|
1151
|
+
end: entry.endOffset,
|
|
1152
|
+
content: entry.originalContent,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const src = locateElement(code, tracked.get(id)!);
|
|
1158
|
+
const tgt = locateElement(code, tracked.get(targetId)!);
|
|
1159
|
+
if (!src || !tgt) {
|
|
1160
|
+
return { success: false, error: `Element not found in ${file}. The file may have been modified.` };
|
|
1161
|
+
}
|
|
1162
|
+
if (tgt.start >= src.start && tgt.end <= src.end) {
|
|
1163
|
+
return { success: false, error: "Cannot move an element inside itself" };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const edit = moveRange(code, src, tgt, position);
|
|
1167
|
+
codes.set(file, edit.code);
|
|
1168
|
+
for (const [elementId, el] of tracked) {
|
|
1169
|
+
if (elementId === id) continue;
|
|
1170
|
+
if (elements[elementId].file !== file) continue;
|
|
1171
|
+
if (el.start >= src.start && el.end <= src.end) {
|
|
1172
|
+
// Inside the moved element: shifted along with it (re-indentation is caught up
|
|
1173
|
+
// by the whitespace-insensitive lookup of locateElement).
|
|
1174
|
+
const delta = edit.moved.start - src.start;
|
|
1175
|
+
tracked.set(elementId, { ...el, start: el.start + delta, end: el.end + delta });
|
|
1176
|
+
} else {
|
|
1177
|
+
tracked.set(elementId, {
|
|
1178
|
+
...el,
|
|
1179
|
+
start: mapOffset(edit, el.start, false),
|
|
1180
|
+
end: mapOffset(edit, el.end, true),
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
tracked.set(id, { ...edit.moved, content: edit.code.slice(edit.moved.start, edit.moved.end) });
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const changed: [filePath: string, code: string][] = [];
|
|
1188
|
+
for (const [file, code] of codes) {
|
|
1189
|
+
const filePath = path.join(this.projectRoot, file);
|
|
1190
|
+
if (code === (await fs.readFile(filePath, "utf-8"))) continue;
|
|
1191
|
+
if (parseSync(file, code, { sourceType: "module" }).errors.length > 0) {
|
|
1192
|
+
return { success: false, error: `Moving the elements would produce invalid code in ${file}` };
|
|
1193
|
+
}
|
|
1194
|
+
changed.push([filePath, code]);
|
|
1195
|
+
}
|
|
1196
|
+
for (const [filePath, code] of changed) await fs.writeFile(filePath, code);
|
|
1197
|
+
if (changed.length > 0) this.registry = null;
|
|
1198
|
+
return { success: true, filePaths: changed.map(([filePath]) => filePath) };
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
return { success: false, error: String(err) };
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
941
1204
|
/**
|
|
942
1205
|
* Read the page metadata shown in the browser tab and in search results.
|
|
943
1206
|
*
|