@upstart.gg/vite-plugins 0.1.83 → 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.
Files changed (44) hide show
  1. package/dist/site-theme.d.ts +55 -0
  2. package/dist/site-theme.d.ts.map +1 -0
  3. package/dist/site-theme.js +184 -0
  4. package/dist/site-theme.js.map +1 -0
  5. package/dist/upstart-editor-api.d.ts +63 -1
  6. package/dist/upstart-editor-api.d.ts.map +1 -1
  7. package/dist/upstart-editor-api.js +233 -1
  8. package/dist/upstart-editor-api.js.map +1 -1
  9. package/dist/vite-plugin-upstart-attrs.d.ts +1 -1
  10. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  11. package/dist/vite-plugin-upstart-attrs.js +65 -2
  12. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  13. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js +2 -24
  14. package/dist/vite-plugin-upstart-editor/runtime/array-controls.js.map +1 -1
  15. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +7 -1
  16. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  17. package/dist/vite-plugin-upstart-editor/runtime/controls-ui.js +33 -0
  18. package/dist/vite-plugin-upstart-editor/runtime/controls-ui.js.map +1 -0
  19. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  20. package/dist/vite-plugin-upstart-editor/runtime/index.js +35 -0
  21. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  22. package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts +5 -0
  23. package/dist/vite-plugin-upstart-editor/runtime/labels.d.ts.map +1 -1
  24. package/dist/vite-plugin-upstart-editor/runtime/labels.js +5 -0
  25. package/dist/vite-plugin-upstart-editor/runtime/labels.js.map +1 -1
  26. package/dist/vite-plugin-upstart-editor/runtime/move-controls.js +434 -0
  27. package/dist/vite-plugin-upstart-editor/runtime/move-controls.js.map +1 -0
  28. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +9 -3
  29. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  30. package/package.json +3 -3
  31. package/src/site-theme.ts +205 -0
  32. package/src/tests/move-controls.test.ts +86 -0
  33. package/src/tests/site-theme.test.ts +122 -0
  34. package/src/tests/upstart-editor-api-move-element.test.ts +220 -0
  35. package/src/tests/vite-plugin-upstart-attrs.test.ts +165 -4
  36. package/src/upstart-editor-api.ts +294 -0
  37. package/src/vite-plugin-upstart-attrs.ts +93 -2
  38. package/src/vite-plugin-upstart-editor/runtime/array-controls.ts +2 -40
  39. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +17 -3
  40. package/src/vite-plugin-upstart-editor/runtime/controls-ui.ts +48 -0
  41. package/src/vite-plugin-upstart-editor/runtime/index.ts +46 -0
  42. package/src/vite-plugin-upstart-editor/runtime/labels.ts +10 -0
  43. package/src/vite-plugin-upstart-editor/runtime/move-controls.ts +536 -0
  44. package/src/vite-plugin-upstart-editor/runtime/types.ts +9 -2
@@ -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 NOT almost-leaf because
1585
- // its <span> children have attributes (className), which disqualifies them as
1586
- // simple inline formatting wrappers.
1587
- expect(entries.length).toBe(4);
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 = `
@@ -6,6 +6,27 @@ import z from "zod";
6
6
  import type { EditableEntry } from "./vite-plugin-upstart-attrs";
7
7
  import { analyzeRouteMeta, type MetaElement, type MetaSegment, type ModuleLoader } from "./page-meta";
8
8
  import { ensureRootSiteMeta } from "./site-meta";
9
+ import {
10
+ getSiteTheme,
11
+ setSiteTheme,
12
+ type GetThemeResult,
13
+ type PayloadGetTheme,
14
+ type PayloadSetTheme,
15
+ type SetThemeResult,
16
+ } from "./site-theme";
17
+
18
+ export {
19
+ EDITABLE_THEME_COLORS,
20
+ payloadGetTheme,
21
+ payloadSetTheme,
22
+ type EditableThemeColor,
23
+ type GetThemeResult,
24
+ type PayloadGetTheme,
25
+ type PayloadSetTheme,
26
+ type SetThemeResult,
27
+ type ThemeColors,
28
+ type ThemePalette,
29
+ } from "./site-theme";
9
30
 
10
31
  /**
11
32
  * Escape user-typed text so it can be safely written as the BODY of a JS string
@@ -320,6 +341,150 @@ function printMetaTag(fieldName: string, value: string): string {
320
341
  return `<meta name="${fieldName}" ${attribute} />`;
321
342
  }
322
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
+
323
488
  /** Start of the line `offset` sits on, so a removed tag doesn't leave a blank line behind. */
324
489
  function lineRangeStart(code: string, offset: number): number {
325
490
  const lineStart = code.lastIndexOf("\n", offset);
@@ -401,6 +566,27 @@ export const payloadArraySet = z.object({
401
566
 
402
567
  export type PayloadArraySet = z.infer<typeof payloadArraySet>;
403
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
+
404
590
  // React-router route id, as reported by the client runtime (e.g. "routes/_layout._index",
405
591
  // "root"). Resolved to `<projectRoot>/app/<routeId>.<ext>` — see `resolveRouteFile`.
406
592
  const routeIdSchema = z
@@ -917,6 +1103,104 @@ export class UpstartEditorAPI {
917
1103
  }
918
1104
  }
919
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
+
920
1204
  /**
921
1205
  * Read the page metadata shown in the browser tab and in search results.
922
1206
  *
@@ -1236,6 +1520,16 @@ export class UpstartEditorAPI {
1236
1520
  }
1237
1521
  }
1238
1522
 
1523
+ /** The site's colors and, for a new site, the palettes offered with its mockup (see site-theme.ts). */
1524
+ async getTheme(_params: PayloadGetTheme): Promise<GetThemeResult> {
1525
+ return getSiteTheme(this.projectRoot);
1526
+ }
1527
+
1528
+ /** Write colors to the site's theme file, and record in the brief that the user chose them. */
1529
+ async setTheme(params: PayloadSetTheme): Promise<SetThemeResult> {
1530
+ return setSiteTheme(this.projectRoot, params);
1531
+ }
1532
+
1239
1533
  private async readSiteConfig(): Promise<Record<string, unknown> | null> {
1240
1534
  try {
1241
1535
  const raw = await fs.readFile(path.join(this.projectRoot, "app", "config", "site.json"), "utf-8");
@@ -70,7 +70,8 @@ export interface EditableSegment {
70
70
  // Registry entry for editable elements (text, rich-text, className, mixed-text)
71
71
  export interface EditableEntry {
72
72
  file: string;
73
- type: "text" | "rich-text" | "className" | "mixed-text" | "image";
73
+ // "element": the full source range of a JSX element the editor can move (drag & drop).
74
+ type: "text" | "rich-text" | "className" | "mixed-text" | "image" | "element";
74
75
  startOffset: number;
75
76
  endOffset: number;
76
77
  originalContent: string;
@@ -120,6 +121,8 @@ interface TransformState {
120
121
  * `function Hero({ heroImageSrc = "/images/hero.webp" })`.
121
122
  */
122
123
  paramDefaults: Map<string, StringLiteralNode>[];
124
+ /** Start offsets of the enclosing functions, innermost last — used as the drag & drop scope. */
125
+ functionStack: number[];
123
126
  }
124
127
 
125
128
  type StringLiteralNode = NodeWithRange & { type: "Literal"; value: string };
@@ -290,13 +293,14 @@ export function transformWithOxc(code: string, filePath: string) {
290
293
  tFunctions: new Map(),
291
294
  constantNodes: new Map(),
292
295
  paramDefaults: [],
296
+ functionStack: [],
293
297
  };
294
298
 
295
299
  let modified = false;
296
300
 
297
301
  // Use zimmerframe to walk and transform the AST
298
302
  walk(ast.program as Program, state, {
299
- _(node: Node, { state, next }: { state: TransformState; next: () => void }) {
303
+ _(node: Node, { state, next, path }: { state: TransformState; next: () => void; path: Node[] }) {
300
304
  // Track useTranslation() calls to resolve t() function namespaces
301
305
  if (node.type === "VariableDeclaration") {
302
306
  trackUseTranslation(node, state);
@@ -326,7 +330,9 @@ export function transformWithOxc(code: string, filePath: string) {
326
330
  node.type === "ArrowFunctionExpression"
327
331
  ) {
328
332
  state.paramDefaults.push(collectParamStringDefaults((node as any).params ?? []));
333
+ state.functionStack.push(hasRange(node) ? node.start : -1);
329
334
  next();
335
+ state.functionStack.pop();
330
336
  state.paramDefaults.pop();
331
337
  return;
332
338
  }
@@ -715,6 +721,44 @@ export function transformWithOxc(code: string, filePath: string) {
715
721
  }
716
722
  }
717
723
 
724
+ // Movable element (editor drag & drop): any HTML element that is a direct child of
725
+ // another JSX element, so moving its source range keeps valid JSX. Elements rendered in
726
+ // a loop or conditionally are never movable, nor components (their DOM may not carry
727
+ // the attributes) and what lives in <head>, an <svg> or a <Trans>. The only content of an
728
+ // HTML element is not movable either: its parent is what gets moved (one handle per
729
+ // visual block, and no empty wrapper left behind).
730
+ const scopeStart = state.functionStack[state.functionStack.length - 1];
731
+ const parent = path[path.length - 1];
732
+ if (
733
+ tagName &&
734
+ /^[a-z]/.test(tagName) &&
735
+ !NON_MOVABLE_TAGS.has(tagName) &&
736
+ !isInsideNonMovableContainer(path) &&
737
+ !isOnlyContentOfHtmlElement(jsxNode, parent) &&
738
+ state.loopStack.length === 0 &&
739
+ scopeStart !== undefined &&
740
+ scopeStart >= 0 &&
741
+ (parent?.type === "JSXElement" || parent?.type === "JSXFragment") &&
742
+ hasRange(jsxNode)
743
+ ) {
744
+ const id = generateId(state.filePath, jsxNode);
745
+ editableRegistry.set(id, {
746
+ file: state.filePath,
747
+ type: "element",
748
+ startOffset: jsxNode.start,
749
+ endOffset: jsxNode.end,
750
+ originalContent: state.code.slice(jsxNode.start, jsxNode.end),
751
+ context: {
752
+ parentTag:
753
+ parent.type === "JSXElement"
754
+ ? (getJSXElementName(parent.openingElement) ?? "unknown")
755
+ : "fragment",
756
+ },
757
+ });
758
+ attributes.push(`data-upstart-move-id="${escapeProp(id)}"`);
759
+ attributes.push(`data-upstart-move-scope="${escapeProp(`${state.filePath}:${scopeStart}`)}"`);
760
+ }
761
+
718
762
  // Inject attributes if any
719
763
  if (insertPos !== -1 && attributes.length > 0) {
720
764
  const attrString = " " + attributes.join(" ");
@@ -737,6 +781,53 @@ export function transformWithOxc(code: string, filePath: string) {
737
781
  };
738
782
  }
739
783
 
784
+ // Elements the editor never offers to move (document structure, invisible or meaningless alone).
785
+ const NON_MOVABLE_TAGS = new Set([
786
+ "html",
787
+ "head",
788
+ "body",
789
+ "meta",
790
+ "link",
791
+ "script",
792
+ "style",
793
+ "title",
794
+ "base",
795
+ "br",
796
+ "wbr",
797
+ "source",
798
+ "track",
799
+ "option",
800
+ "optgroup",
801
+ ]);
802
+
803
+ // Ancestors whose children are not movable: <head> content, SVG internals, <Trans> slots.
804
+ const NON_MOVABLE_CONTAINERS = new Set(["head", "svg", "Trans"]);
805
+
806
+ function isInsideNonMovableContainer(path: Node[]): boolean {
807
+ return path.some((node) => {
808
+ if (node.type !== "JSXElement") return false;
809
+ const name = getJSXElementName((node as JSXElement).openingElement);
810
+ return name !== null && NON_MOVABLE_CONTAINERS.has(name);
811
+ });
812
+ }
813
+
814
+ /**
815
+ * True when `node` is the only meaningful child of an HTML element: no sibling element,
816
+ * non-blank text or expression. Children of components are not concerned (a component is
817
+ * never movable itself, so its child stays the way to move that content).
818
+ */
819
+ function isOnlyContentOfHtmlElement(node: JSXElement, parent: Node | undefined): boolean {
820
+ if (parent?.type !== "JSXElement") return false;
821
+ const parentName = getJSXElementName((parent as JSXElement).openingElement);
822
+ if (!parentName || !/^[a-z]/.test(parentName)) return false;
823
+ return (parent as JSXElement).children.every((child) => {
824
+ if (child === node) return true;
825
+ if (child.type === "JSXText") return (child as { value: string }).value.trim() === "";
826
+ if (child.type === "JSXExpressionContainer") return child.expression.type === "JSXEmptyExpression";
827
+ return false;
828
+ });
829
+ }
830
+
740
831
  // Helper: Get JSX element name
741
832
  function getJSXElementName(opening: JSXOpeningElement): string | null {
742
833
  if (opening.name.type === "JSXIdentifier") {