@upstart.gg/vite-plugins 0.1.83 → 0.1.84
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 +6 -1
- package/dist/upstart-editor-api.d.ts.map +1 -1
- package/dist/upstart-editor-api.js +10 -1
- package/dist/upstart-editor-api.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.js +12 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +3 -0
- 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/site-theme.test.ts +122 -0
- package/src/upstart-editor-api.ts +31 -0
- package/src/vite-plugin-upstart-editor/runtime/index.ts +15 -0
- package/src/vite-plugin-upstart-editor/runtime/types.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@upstart.gg/vite-plugins",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.84",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"oxc-parser": "0.101.0",
|
|
24
24
|
"unplugin": "^2.3.11",
|
|
25
25
|
"zimmerframe": "^1.1.4",
|
|
26
|
-
"@upstart.gg/sdk": "^0.1.
|
|
26
|
+
"@upstart.gg/sdk": "^0.1.84"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
},
|
|
119
119
|
"peerDependencies": {
|
|
120
120
|
"zod": "4.3.6",
|
|
121
|
-
"@upstart.gg/sdk": "^0.1.
|
|
121
|
+
"@upstart.gg/sdk": "^0.1.84"
|
|
122
122
|
},
|
|
123
123
|
"author": "Upstart",
|
|
124
124
|
"publishConfig": {
|
|
@@ -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,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
|
+
});
|
|
@@ -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
|
|
@@ -1236,6 +1257,16 @@ export class UpstartEditorAPI {
|
|
|
1236
1257
|
}
|
|
1237
1258
|
}
|
|
1238
1259
|
|
|
1260
|
+
/** The site's colors and, for a new site, the palettes offered with its mockup (see site-theme.ts). */
|
|
1261
|
+
async getTheme(_params: PayloadGetTheme): Promise<GetThemeResult> {
|
|
1262
|
+
return getSiteTheme(this.projectRoot);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** Write colors to the site's theme file, and record in the brief that the user chose them. */
|
|
1266
|
+
async setTheme(params: PayloadSetTheme): Promise<SetThemeResult> {
|
|
1267
|
+
return setSiteTheme(this.projectRoot, params);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1239
1270
|
private async readSiteConfig(): Promise<Record<string, unknown> | null> {
|
|
1240
1271
|
try {
|
|
1241
1272
|
const raw = await fs.readFile(path.join(this.projectRoot, "app", "config", "site.json"), "utf-8");
|
|
@@ -237,6 +237,21 @@ function handleParentMessage(event: MessageEvent): void {
|
|
|
237
237
|
} else if (styleEl) {
|
|
238
238
|
styleEl.remove();
|
|
239
239
|
}
|
|
240
|
+
} else if (message.type === "preview-theme") {
|
|
241
|
+
// Appended last in <head> so it wins over the theme's own variables until it is cleared.
|
|
242
|
+
const STYLE_ID = "upstart-preview-theme";
|
|
243
|
+
let styleEl = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
|
244
|
+
const css = typeof message.css === "string" ? message.css : "";
|
|
245
|
+
if (css) {
|
|
246
|
+
if (!styleEl) {
|
|
247
|
+
styleEl = document.createElement("style");
|
|
248
|
+
styleEl.id = STYLE_ID;
|
|
249
|
+
}
|
|
250
|
+
styleEl.textContent = css;
|
|
251
|
+
document.head.appendChild(styleEl);
|
|
252
|
+
} else if (styleEl) {
|
|
253
|
+
styleEl.remove();
|
|
254
|
+
}
|
|
240
255
|
} else if (message.type === "request-scroll-position") {
|
|
241
256
|
sendToParent({ type: "scroll-position", x: window.scrollX, y: window.scrollY });
|
|
242
257
|
} else if (message.type === "restore-scroll-position") {
|
|
@@ -152,6 +152,8 @@ export type ParentMessage =
|
|
|
152
152
|
| { type: "preview-image"; imageId: string; src: string }
|
|
153
153
|
/** Live-preview object-fit / position / zoom of one image via a dedicated <style> element. Empty css clears it. */
|
|
154
154
|
| { type: "preview-image-style"; imageId: string; css: string }
|
|
155
|
+
/** Live-preview of the site's colors (theme CSS variables) before they are saved. Empty css clears it. */
|
|
156
|
+
| { type: "preview-theme"; css: string }
|
|
155
157
|
| { type: "request-scroll-position" }
|
|
156
158
|
| { type: "restore-scroll-position"; x: number; y: number }
|
|
157
159
|
| { type: "clear-selection" }
|