@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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's colors, editable from the editor's "Colors" panel.
|
|
3
|
+
*
|
|
4
|
+
* A site's colors live in its theme file (`app/config/themes/<id>.json`, the one `site.json` names
|
|
5
|
+
* as the default), read by the theme plugin at build time. A new site also keeps the palettes the
|
|
6
|
+
* client was offered when choosing its look (`docs/mockups/<direction>/` and its `palettes/<n>/`
|
|
7
|
+
* variants, see the `PaletteVariants` tool): the panel offers them again, next to a free picker.
|
|
8
|
+
*
|
|
9
|
+
* Saving also records in `docs/DESIGN.md` that the user chose the colors themselves: agents read
|
|
10
|
+
* the brief before any UI work, and would otherwise bring the brief's palette back.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "fs/promises";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import z from "zod";
|
|
16
|
+
|
|
17
|
+
/** The theme colors the panel edits, in the theme file's naming. */
|
|
18
|
+
export const EDITABLE_THEME_COLORS = [
|
|
19
|
+
"base100",
|
|
20
|
+
"base200",
|
|
21
|
+
"base300",
|
|
22
|
+
"baseContent",
|
|
23
|
+
"primary",
|
|
24
|
+
"primaryContent",
|
|
25
|
+
"secondary",
|
|
26
|
+
"secondaryContent",
|
|
27
|
+
"accent",
|
|
28
|
+
"accentContent",
|
|
29
|
+
"neutral",
|
|
30
|
+
"neutralContent",
|
|
31
|
+
] as const;
|
|
32
|
+
export type EditableThemeColor = (typeof EDITABLE_THEME_COLORS)[number];
|
|
33
|
+
export type ThemeColors = Partial<Record<EditableThemeColor, string>>;
|
|
34
|
+
|
|
35
|
+
/** A CSS color as theme files hold them: oklch(), hex, rgb() or hsl(). */
|
|
36
|
+
const cssColor = z
|
|
37
|
+
.string()
|
|
38
|
+
.trim()
|
|
39
|
+
.max(80)
|
|
40
|
+
.regex(/^(#[0-9a-fA-F]{3,8}|(oklch|oklab|rgba?|hsla?)\([0-9.,%\s/+-]+\))$/, "Not a CSS color");
|
|
41
|
+
|
|
42
|
+
export const payloadGetTheme = z.object({ action: z.literal("getTheme") });
|
|
43
|
+
export type PayloadGetTheme = z.infer<typeof payloadGetTheme>;
|
|
44
|
+
|
|
45
|
+
export const payloadSetTheme = z.object({
|
|
46
|
+
action: z.literal("setTheme"),
|
|
47
|
+
colors: z.partialRecord(z.enum(EDITABLE_THEME_COLORS), cssColor),
|
|
48
|
+
});
|
|
49
|
+
export type PayloadSetTheme = z.infer<typeof payloadSetTheme>;
|
|
50
|
+
|
|
51
|
+
export interface ThemePalette {
|
|
52
|
+
/** Stable id: "current" for the chosen mockup, "palette-<n>" for its variants. */
|
|
53
|
+
id: string;
|
|
54
|
+
/** The catalog palette's description, when known (e.g. "Blush base, deep rose text…"). */
|
|
55
|
+
label: string;
|
|
56
|
+
colors: ThemeColors;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type GetThemeResult =
|
|
60
|
+
| { success: true; themeId: string; colors: ThemeColors; palettes: ThemePalette[] }
|
|
61
|
+
| { success: false; error: string };
|
|
62
|
+
|
|
63
|
+
export type SetThemeResult = { success: true; filePaths: string[] } | { success: false; error: string };
|
|
64
|
+
|
|
65
|
+
/** Mockup token (Tailwind config of a mockup) → theme color. */
|
|
66
|
+
const MOCKUP_TOKENS: Record<string, EditableThemeColor> = {
|
|
67
|
+
"base-100": "base100",
|
|
68
|
+
"base-200": "base200",
|
|
69
|
+
"base-300": "base300",
|
|
70
|
+
"base-content": "baseContent",
|
|
71
|
+
primary: "primary",
|
|
72
|
+
"primary-content": "primaryContent",
|
|
73
|
+
secondary: "secondary",
|
|
74
|
+
"secondary-content": "secondaryContent",
|
|
75
|
+
accent: "accent",
|
|
76
|
+
"accent-content": "accentContent",
|
|
77
|
+
neutral: "neutral",
|
|
78
|
+
"neutral-content": "neutralContent",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The colors a mockup sets in its Tailwind config, in the theme's naming. */
|
|
82
|
+
export function mockupThemeColors(html: string): ThemeColors {
|
|
83
|
+
const script = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)].find((m) =>
|
|
84
|
+
m[1].includes("tailwind.config"),
|
|
85
|
+
)?.[1];
|
|
86
|
+
const colors: ThemeColors = {};
|
|
87
|
+
if (!script) return colors;
|
|
88
|
+
for (const [token, key] of Object.entries(MOCKUP_TOKENS)) {
|
|
89
|
+
const pattern = new RegExp(`['"]?${token.replace(/-/g, "\\-")}['"]?\\s*:\\s*(['"])([^'"]*)\\1`);
|
|
90
|
+
const value = pattern.exec(script)?.[2];
|
|
91
|
+
if (value && cssColor.safeParse(value).success) colors[key] = value.trim();
|
|
92
|
+
}
|
|
93
|
+
return colors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The block `setTheme` keeps up to date at the end of the brief. */
|
|
97
|
+
const BRIEF_START = "<!-- upstart:theme-colors -->";
|
|
98
|
+
const BRIEF_END = "<!-- /upstart:theme-colors -->";
|
|
99
|
+
|
|
100
|
+
export function briefWithUserColors(brief: string, themeFile: string): string {
|
|
101
|
+
const block = [
|
|
102
|
+
BRIEF_START,
|
|
103
|
+
"## Colors chosen by the user",
|
|
104
|
+
`The user set the site's colors themselves in the editor. \`${themeFile}\` is the source of truth for colors and wins over any palette above: keep its values, and use its tokens (bg-base-100, text-primary…) rather than new colors.`,
|
|
105
|
+
BRIEF_END,
|
|
106
|
+
].join("\n");
|
|
107
|
+
const start = brief.indexOf(BRIEF_START);
|
|
108
|
+
const end = brief.indexOf(BRIEF_END);
|
|
109
|
+
if (start !== -1 && end > start) {
|
|
110
|
+
return `${brief.slice(0, start)}${block}${brief.slice(end + BRIEF_END.length)}`;
|
|
111
|
+
}
|
|
112
|
+
return `${brief.trimEnd()}\n\n${block}\n`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The theme file the site uses by default, from `app/config/site.json`. */
|
|
116
|
+
async function defaultThemeFile(projectRoot: string): Promise<{ themeId: string; filePath: string } | null> {
|
|
117
|
+
try {
|
|
118
|
+
const site = JSON.parse(
|
|
119
|
+
await fs.readFile(path.join(projectRoot, "app", "config", "site.json"), "utf-8"),
|
|
120
|
+
) as {
|
|
121
|
+
themes?: { light?: string; dark?: string; default?: "light" | "dark" };
|
|
122
|
+
};
|
|
123
|
+
const themeId = site.themes?.[site.themes.default ?? "light"] ?? site.themes?.light;
|
|
124
|
+
if (!themeId || !/^[a-z0-9-]+$/.test(themeId)) return null;
|
|
125
|
+
return { themeId, filePath: path.join(projectRoot, "app", "config", "themes", `${themeId}.json`) };
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The mockup the brief names as its reference (`docs/mockups/<direction>/…`). */
|
|
132
|
+
async function chosenMockup(projectRoot: string): Promise<string | null> {
|
|
133
|
+
const brief = await fs.readFile(path.join(projectRoot, "docs", "DESIGN.md"), "utf-8").catch(() => "");
|
|
134
|
+
return /docs\/mockups\/([a-z0-9-]+)\//.exec(brief)?.[1] ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The palettes the client was offered for the chosen mockup; none for an older site. */
|
|
138
|
+
export async function offeredPalettes(projectRoot: string): Promise<ThemePalette[]> {
|
|
139
|
+
const direction = await chosenMockup(projectRoot);
|
|
140
|
+
if (!direction) return [];
|
|
141
|
+
const dir = path.join(projectRoot, "docs", "mockups", direction);
|
|
142
|
+
const readPalette = async (id: string, folder: string): Promise<ThemePalette | null> => {
|
|
143
|
+
const html = await fs.readFile(path.join(folder, "index.html"), "utf-8").catch(() => "");
|
|
144
|
+
const colors = mockupThemeColors(html);
|
|
145
|
+
if (Object.keys(colors).length < 3) return null;
|
|
146
|
+
const variant = await fs
|
|
147
|
+
.readFile(path.join(folder, "variant.json"), "utf-8")
|
|
148
|
+
.then((raw) => JSON.parse(raw) as { palette?: string })
|
|
149
|
+
.catch((): { palette?: string } => ({}));
|
|
150
|
+
return { id, label: variant.palette ?? "", colors };
|
|
151
|
+
};
|
|
152
|
+
const variants = await fs.readdir(path.join(dir, "palettes")).catch((): string[] => []);
|
|
153
|
+
const palettes = await Promise.all([
|
|
154
|
+
readPalette("current", dir),
|
|
155
|
+
...variants
|
|
156
|
+
.filter((n) => /^\d+$/.test(n))
|
|
157
|
+
.sort((a, b) => Number(a) - Number(b))
|
|
158
|
+
.map((n) => readPalette(`palette-${n}`, path.join(dir, "palettes", n))),
|
|
159
|
+
]);
|
|
160
|
+
return palettes.filter((p): p is ThemePalette => p !== null);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function getSiteTheme(projectRoot: string): Promise<GetThemeResult> {
|
|
164
|
+
const theme = await defaultThemeFile(projectRoot);
|
|
165
|
+
if (!theme) return { success: false, error: "This site has no theme in app/config/site.json" };
|
|
166
|
+
try {
|
|
167
|
+
const content = JSON.parse(await fs.readFile(theme.filePath, "utf-8")) as {
|
|
168
|
+
colors?: Record<string, string>;
|
|
169
|
+
};
|
|
170
|
+
const colors: ThemeColors = {};
|
|
171
|
+
for (const key of EDITABLE_THEME_COLORS) {
|
|
172
|
+
const value = content.colors?.[key];
|
|
173
|
+
if (typeof value === "string") colors[key] = value;
|
|
174
|
+
}
|
|
175
|
+
return { success: true, themeId: theme.themeId, colors, palettes: await offeredPalettes(projectRoot) };
|
|
176
|
+
} catch {
|
|
177
|
+
return { success: false, error: `The theme file app/config/themes/${theme.themeId}.json is missing` };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function setSiteTheme(projectRoot: string, params: PayloadSetTheme): Promise<SetThemeResult> {
|
|
182
|
+
const parsed = payloadSetTheme.safeParse(params);
|
|
183
|
+
if (!parsed.success) return { success: false, error: `Invalid payload: ${parsed.error.message}` };
|
|
184
|
+
if (Object.keys(parsed.data.colors).length === 0) return { success: true, filePaths: [] };
|
|
185
|
+
const theme = await defaultThemeFile(projectRoot);
|
|
186
|
+
if (!theme) return { success: false, error: "This site has no theme in app/config/site.json" };
|
|
187
|
+
try {
|
|
188
|
+
const content = JSON.parse(await fs.readFile(theme.filePath, "utf-8")) as {
|
|
189
|
+
colors?: Record<string, string>;
|
|
190
|
+
};
|
|
191
|
+
content.colors = { ...content.colors, ...parsed.data.colors };
|
|
192
|
+
await fs.writeFile(theme.filePath, `${JSON.stringify(content, null, 2)}\n`);
|
|
193
|
+
const filePaths = [theme.filePath];
|
|
194
|
+
|
|
195
|
+
const briefPath = path.join(projectRoot, "docs", "DESIGN.md");
|
|
196
|
+
const brief = await fs.readFile(briefPath, "utf-8").catch(() => null);
|
|
197
|
+
if (brief !== null) {
|
|
198
|
+
await fs.writeFile(briefPath, briefWithUserColors(brief, path.relative(projectRoot, theme.filePath)));
|
|
199
|
+
filePaths.push(briefPath);
|
|
200
|
+
}
|
|
201
|
+
return { success: true, filePaths };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { success: false, error: String(err) };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { dropPosition, isSameLevel, pickNearest } from "../vite-plugin-upstart-editor/runtime/move-controls";
|
|
3
|
+
|
|
4
|
+
const rect = (left: number, top: number, width: number, height: number) => ({
|
|
5
|
+
left,
|
|
6
|
+
top,
|
|
7
|
+
width,
|
|
8
|
+
height,
|
|
9
|
+
right: left + width,
|
|
10
|
+
bottom: top + height,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("pickNearest", () => {
|
|
14
|
+
const title = { item: "title", rect: rect(0, 0, 400, 40) };
|
|
15
|
+
const text = { item: "text", rect: rect(0, 60, 400, 100) };
|
|
16
|
+
|
|
17
|
+
test("picks the element under the pointer", () => {
|
|
18
|
+
expect(pickNearest([title, text], 100, 80)?.item).toBe("text");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("picks the innermost of nested elements", () => {
|
|
22
|
+
const strong = { item: "strong", rect: rect(10, 70, 50, 20) };
|
|
23
|
+
expect(pickNearest([text, strong], 20, 75)?.item).toBe("strong");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("snaps to the nearest element in the gap between two elements", () => {
|
|
27
|
+
expect(pickNearest([title, text], 100, 45)?.item).toBe("title");
|
|
28
|
+
expect(pickNearest([title, text], 100, 57)?.item).toBe("text");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("ignores elements too far away and hidden ones", () => {
|
|
32
|
+
expect(pickNearest([title, text], 100, 400)).toBeNull();
|
|
33
|
+
expect(pickNearest([{ item: "hidden", rect: rect(0, 0, 0, 0) }], 0, 0)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("dropPosition", () => {
|
|
38
|
+
const r = rect(0, 0, 200, 100);
|
|
39
|
+
|
|
40
|
+
test("uses the vertical middle for stacked elements", () => {
|
|
41
|
+
expect(dropPosition(r, "vertical", 190, 20)).toBe("before");
|
|
42
|
+
expect(dropPosition(r, "vertical", 10, 80)).toBe("after");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("uses the horizontal middle for side-by-side elements", () => {
|
|
46
|
+
expect(dropPosition(r, "horizontal", 20, 90)).toBe("before");
|
|
47
|
+
expect(dropPosition(r, "horizontal", 150, 10)).toBe("after");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("isSameLevel", () => {
|
|
52
|
+
type Node = { name: string; parentElement: Node | null };
|
|
53
|
+
const node = (name: string, parent: Node | null): Node => ({ name, parentElement: parent });
|
|
54
|
+
|
|
55
|
+
// section > [colA > [img, title], colB > [text], aside > nested > [deep]]
|
|
56
|
+
const root = node("root", null);
|
|
57
|
+
const section = node("section", root);
|
|
58
|
+
const colA = node("colA", section);
|
|
59
|
+
const colB = node("colB", section);
|
|
60
|
+
const img = node("img", colA);
|
|
61
|
+
const title = node("title", colA);
|
|
62
|
+
const text = node("text", colB);
|
|
63
|
+
const aside = node("aside", section);
|
|
64
|
+
const nested = node("nested", aside);
|
|
65
|
+
const deep = node("deep", nested);
|
|
66
|
+
|
|
67
|
+
test("siblings are at the same level", () => {
|
|
68
|
+
expect(isSameLevel(img, title)).toBe(true);
|
|
69
|
+
expect(isSameLevel(colA, colB)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("cousins (children of a neighbouring container) are at the same level", () => {
|
|
73
|
+
expect(isSameLevel(img, text)).toBe(true);
|
|
74
|
+
expect(isSameLevel(text, title)).toBe(true);
|
|
75
|
+
expect(isSameLevel(img, nested)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the element itself, its parent and deeper or higher elements are not", () => {
|
|
79
|
+
expect(isSameLevel(img, img)).toBe(false);
|
|
80
|
+
expect(isSameLevel(img, colA)).toBe(false);
|
|
81
|
+
expect(isSameLevel(img, section)).toBe(false);
|
|
82
|
+
expect(isSameLevel(img, deep)).toBe(false);
|
|
83
|
+
expect(isSameLevel(colA, img)).toBe(false);
|
|
84
|
+
expect(isSameLevel(colA, text)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
briefWithUserColors,
|
|
7
|
+
getSiteTheme,
|
|
8
|
+
mockupThemeColors,
|
|
9
|
+
offeredPalettes,
|
|
10
|
+
setSiteTheme,
|
|
11
|
+
} from "../site-theme";
|
|
12
|
+
|
|
13
|
+
let root: string;
|
|
14
|
+
const write = (rel: string, content: string) => {
|
|
15
|
+
const file = join(root, rel);
|
|
16
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
17
|
+
writeFileSync(file, content);
|
|
18
|
+
};
|
|
19
|
+
const mockup = (base: string, primary: string) => `<html><head><script>
|
|
20
|
+
tailwind.config = { theme: { extend: { colors: {
|
|
21
|
+
'base-100': '${base}',
|
|
22
|
+
"base-content": "oklch(24% 0.03 50)",
|
|
23
|
+
primary: '${primary}',
|
|
24
|
+
'primary-content': 'oklch(98% 0.01 85)',
|
|
25
|
+
} } } };
|
|
26
|
+
</script></head><body></body></html>`;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
root = mkdtempSync(join(tmpdir(), "site-theme-"));
|
|
30
|
+
write("app/config/site.json", JSON.stringify({ themes: { light: "wedding", default: "light" } }));
|
|
31
|
+
write(
|
|
32
|
+
"app/config/themes/wedding.json",
|
|
33
|
+
JSON.stringify({
|
|
34
|
+
id: "wedding",
|
|
35
|
+
colors: { base100: "oklch(98% 0.01 85)", primary: "oklch(50% 0.1 300)", info: "#00f" },
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
40
|
+
|
|
41
|
+
describe("mockupThemeColors", () => {
|
|
42
|
+
it("maps a mockup's Tailwind tokens to the theme's colors", () => {
|
|
43
|
+
expect(mockupThemeColors(mockup("oklch(97% 0.02 15)", "#a33"))).toEqual({
|
|
44
|
+
base100: "oklch(97% 0.02 15)",
|
|
45
|
+
baseContent: "oklch(24% 0.03 50)",
|
|
46
|
+
primary: "#a33",
|
|
47
|
+
primaryContent: "oklch(98% 0.01 85)",
|
|
48
|
+
});
|
|
49
|
+
expect(mockupThemeColors("<html></html>")).toEqual({});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("getSiteTheme", () => {
|
|
54
|
+
it("reads the editable colors of the site's default theme", async () => {
|
|
55
|
+
const theme = await getSiteTheme(root);
|
|
56
|
+
expect(theme).toEqual({
|
|
57
|
+
success: true,
|
|
58
|
+
themeId: "wedding",
|
|
59
|
+
colors: { base100: "oklch(98% 0.01 85)", primary: "oklch(50% 0.1 300)" },
|
|
60
|
+
palettes: [],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("offers the palettes shown with the chosen mockup, the current one first", async () => {
|
|
65
|
+
write("docs/DESIGN.md", "Reference mockup: `docs/mockups/romantic-floral/palettes/2/index.html`");
|
|
66
|
+
write("docs/mockups/romantic-floral/index.html", mockup("oklch(98% 0.01 85)", "oklch(50% 0.1 300)"));
|
|
67
|
+
write("docs/mockups/romantic-floral/variant.json", JSON.stringify({ palette: "Lavender-mist base" }));
|
|
68
|
+
write(
|
|
69
|
+
"docs/mockups/romantic-floral/palettes/2/index.html",
|
|
70
|
+
mockup("oklch(97% 0.02 15)", "oklch(48% 0.14 10)"),
|
|
71
|
+
);
|
|
72
|
+
write(
|
|
73
|
+
"docs/mockups/romantic-floral/palettes/1/index.html",
|
|
74
|
+
mockup("oklch(98% 0.015 95)", "oklch(48% 0.09 235)"),
|
|
75
|
+
);
|
|
76
|
+
write("docs/mockups/romantic-floral/palettes/1/variant.json", JSON.stringify({ palette: "Ivory base" }));
|
|
77
|
+
const palettes = await offeredPalettes(root);
|
|
78
|
+
expect(palettes.map((p) => [p.id, p.label])).toEqual([
|
|
79
|
+
["current", "Lavender-mist base"],
|
|
80
|
+
["palette-1", "Ivory base"],
|
|
81
|
+
["palette-2", ""],
|
|
82
|
+
]);
|
|
83
|
+
expect(palettes[2].colors.primary).toBe("oklch(48% 0.14 10)");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("offers no palette to a site without mockups", async () => {
|
|
87
|
+
write("docs/DESIGN.md", "# Design brief");
|
|
88
|
+
expect(await offeredPalettes(root)).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("setSiteTheme", () => {
|
|
93
|
+
it("merges the colors into the theme file and tells the brief they are the user's", async () => {
|
|
94
|
+
write("docs/DESIGN.md", "# Design brief\n\nPalette: lavender.");
|
|
95
|
+
const result = await setSiteTheme(root, { action: "setTheme", colors: { primary: "#aa3355" } });
|
|
96
|
+
expect(result).toEqual({
|
|
97
|
+
success: true,
|
|
98
|
+
filePaths: [join(root, "app/config/themes/wedding.json"), join(root, "docs/DESIGN.md")],
|
|
99
|
+
});
|
|
100
|
+
const theme = JSON.parse(readFileSync(join(root, "app/config/themes/wedding.json"), "utf-8"));
|
|
101
|
+
expect(theme.colors).toEqual({ base100: "oklch(98% 0.01 85)", primary: "#aa3355", info: "#00f" });
|
|
102
|
+
expect(readFileSync(join(root, "docs/DESIGN.md"), "utf-8")).toContain("## Colors chosen by the user");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("refuses what is not a CSS color", async () => {
|
|
106
|
+
const result = await setSiteTheme(root, {
|
|
107
|
+
action: "setTheme",
|
|
108
|
+
colors: { primary: "red; background: url(x)" },
|
|
109
|
+
});
|
|
110
|
+
expect(result.success).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("briefWithUserColors", () => {
|
|
115
|
+
it("keeps a single block, updated in place", () => {
|
|
116
|
+
const once = briefWithUserColors("# Brief\n", "app/config/themes/a.json");
|
|
117
|
+
const twice = briefWithUserColors(`${once}\n## Later section\n`, "app/config/themes/b.json");
|
|
118
|
+
expect(twice.match(/Colors chosen by the user/g)).toHaveLength(1);
|
|
119
|
+
expect(twice).toContain("app/config/themes/b.json");
|
|
120
|
+
expect(twice).toContain("## Later section");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -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
|
+
});
|