@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.
- package/dist/site-theme.d.ts +55 -0
- package/dist/site-theme.d.ts.map +1 -0
- package/dist/site-theme.js +184 -0
- package/dist/site-theme.js.map +1 -0
- package/dist/upstart-editor-api.d.ts +63 -1
- package/dist/upstart-editor-api.d.ts.map +1 -1
- package/dist/upstart-editor-api.js +233 -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 +35 -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 +9 -3
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/site-theme.ts +205 -0
- package/src/tests/move-controls.test.ts +86 -0
- package/src/tests/site-theme.test.ts +122 -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 +294 -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 +46 -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 +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
|
|
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 = `
|
|
@@ -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
|
-
|
|
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") {
|