@upstart.gg/vite-plugins 0.1.61 → 0.1.62
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/page-meta.js +533 -0
- package/dist/page-meta.js.map +1 -0
- package/dist/site-meta.d.ts +28 -0
- package/dist/site-meta.d.ts.map +1 -0
- package/dist/site-meta.js +207 -0
- package/dist/site-meta.js.map +1 -0
- package/dist/upstart-editor-api.d.ts +135 -1
- package/dist/upstart-editor-api.d.ts.map +1 -1
- package/dist/upstart-editor-api.js +756 -1
- package/dist/upstart-editor-api.js.map +1 -1
- 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/types.d.ts +3 -0
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
- package/package.json +8 -3
- package/src/page-meta.ts +678 -0
- package/src/site-meta.ts +226 -0
- package/src/tests/site-meta.test.ts +158 -0
- package/src/tests/upstart-editor-api-page-meta.test.ts +635 -0
- package/src/upstart-editor-api.ts +941 -0
- package/src/vite-plugin-upstart-editor/runtime/index.ts +38 -0
- package/src/vite-plugin-upstart-editor/runtime/types.ts +2 -0
package/src/site-meta.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import MagicString from "magic-string";
|
|
2
|
+
import { parseSync } from "oxc-parser";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Brings an existing site's `app/root.tsx` up to the version that renders the site-wide
|
|
6
|
+
* settings edited from the editor: the favicon and the social preview image.
|
|
7
|
+
*
|
|
8
|
+
* Sites created from the template already contain this code; sites created before it need a
|
|
9
|
+
* one-off upgrade. `root.tsx` is template-owned (the assistant never edits it), so the shapes
|
|
10
|
+
* matched here are stable — but every step still asserts what it found and the result is
|
|
11
|
+
* re-parsed, so a customized file is reported instead of being mangled.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface AstNode {
|
|
15
|
+
type: string;
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type EnsureRootResult = { ok: true; code: string; changed: boolean } | { ok: false; reason: string };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Icon files left untouched: an ICO is already small and an SVG scales on its own. Every
|
|
25
|
+
* other image is downscaled to a square PNG, because a favicon is drawn at about 16px and
|
|
26
|
+
* WebP is not accepted as an icon everywhere.
|
|
27
|
+
*/
|
|
28
|
+
export const FAVICON_PASS_THROUGH_TYPES = ["image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon"];
|
|
29
|
+
/** Extensions matching {@link FAVICON_PASS_THROUGH_TYPES}, for callers working from a filename. */
|
|
30
|
+
export const FAVICON_PASS_THROUGH_EXTENSIONS = [".svg", ".ico"];
|
|
31
|
+
/** Square size a favicon is downscaled to. */
|
|
32
|
+
export const FAVICON_SIZE = 64;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Name of the downscaled copy of a favicon source. Sources are often already called
|
|
36
|
+
* "favicon", so the suffix is only added when it would say something new.
|
|
37
|
+
*/
|
|
38
|
+
export function faviconFileName(sourceName: string): string {
|
|
39
|
+
const stem = sourceName.replace(/\.[^.]+$/, "");
|
|
40
|
+
return /favicon$/i.test(stem) ? `${stem}.png` : `${stem}-favicon.png`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Marker proving the file already renders the site settings. */
|
|
44
|
+
const MARKER = "siteAttributes.socialImage";
|
|
45
|
+
|
|
46
|
+
const SITE_ATTRIBUTES_DECL =
|
|
47
|
+
"/** Site-wide settings edited from the editor's settings panel (favicon, social preview). */\n" +
|
|
48
|
+
"const siteAttributes = siteConfig as unknown as SiteAttributes;\n\n";
|
|
49
|
+
|
|
50
|
+
const SOCIAL_META = `{/* Preview card shown when a page is shared on social networks or messaging apps.
|
|
51
|
+
React hoists these into <head>. Pages provide the title and description
|
|
52
|
+
themselves; crawlers fall back to <title> and <meta name="description">. */}
|
|
53
|
+
{siteAttributes.socialImage && (
|
|
54
|
+
<>
|
|
55
|
+
<meta property="og:image" content={\`\${origin}\${siteAttributes.socialImage}\`} />
|
|
56
|
+
<meta property="og:type" content="website" />
|
|
57
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
58
|
+
</>
|
|
59
|
+
)}`;
|
|
60
|
+
|
|
61
|
+
function walk(node: unknown, visit: (n: AstNode) => void): void {
|
|
62
|
+
if (!node || typeof node !== "object") return;
|
|
63
|
+
if (Array.isArray(node)) {
|
|
64
|
+
for (const child of node) walk(child, visit);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const n = node as AstNode;
|
|
68
|
+
if (typeof n.type === "string") visit(n);
|
|
69
|
+
for (const key in n) {
|
|
70
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
71
|
+
const value = n[key];
|
|
72
|
+
if (value && typeof value === "object") walk(value, visit);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const propertyName = (prop: AstNode): string | null => {
|
|
77
|
+
const key = prop.key as AstNode | undefined;
|
|
78
|
+
if (key?.type === "Identifier") return key.name as string;
|
|
79
|
+
if (key?.type === "Literal" && typeof key.value === "string") return key.value;
|
|
80
|
+
return null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Top-level `export …` declaration named `name`, whatever form it takes. */
|
|
84
|
+
function findExport(program: AstNode, name: string): AstNode | null {
|
|
85
|
+
for (const statement of (program.body as AstNode[]) ?? []) {
|
|
86
|
+
const isDefault = statement.type === "ExportDefaultDeclaration";
|
|
87
|
+
if (statement.type !== "ExportNamedDeclaration" && !isDefault) continue;
|
|
88
|
+
const declaration = statement.declaration as AstNode | null;
|
|
89
|
+
if (!declaration) continue;
|
|
90
|
+
if (isDefault && name === "default") return declaration;
|
|
91
|
+
if (declaration.type === "FunctionDeclaration") {
|
|
92
|
+
const id = declaration.id as AstNode | null;
|
|
93
|
+
if (id?.type === "Identifier" && id.name === name) return declaration;
|
|
94
|
+
} else if (declaration.type === "VariableDeclaration") {
|
|
95
|
+
for (const decl of (declaration.declarations as AstNode[]) ?? []) {
|
|
96
|
+
const id = decl.id as AstNode;
|
|
97
|
+
if (id?.type === "Identifier" && id.name === name) return (decl.init as AstNode) ?? null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Add a property to a destructuring pattern, unless it is already there. */
|
|
105
|
+
function addToObjectPattern(s: MagicString, code: string, pattern: AstNode, name: string): boolean {
|
|
106
|
+
const properties = (pattern.properties as AstNode[]) ?? [];
|
|
107
|
+
if (properties.some((prop) => prop.type === "Property" && propertyName(prop) === name)) return false;
|
|
108
|
+
const last = properties[properties.length - 1];
|
|
109
|
+
if (!last) return false;
|
|
110
|
+
// Keep the source's layout: one property per line stays one property per line.
|
|
111
|
+
const multiline = code.slice(last.end, pattern.end).includes("\n");
|
|
112
|
+
const indent = multiline
|
|
113
|
+
? (code.slice(code.lastIndexOf("\n", last.start) + 1, last.start).match(/^[ \t]*/)?.[0] ?? "")
|
|
114
|
+
: "";
|
|
115
|
+
s.appendLeft(last.end, multiline ? `,\n${indent}${name}` : `, ${name}`);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function ensureRootSiteMeta(code: string, filePath: string): EnsureRootResult {
|
|
120
|
+
if (code.includes(MARKER)) return { ok: true, code, changed: false };
|
|
121
|
+
if (!code.includes("./config/site.json") || !code.includes("SiteAttributes")) {
|
|
122
|
+
return { ok: false, reason: "root.tsx does not read the site configuration" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const program = parseSync(filePath, code, { sourceType: "module" }).program as unknown as AstNode;
|
|
126
|
+
const s = new MagicString(code);
|
|
127
|
+
|
|
128
|
+
// 1. Module-level view of site.json, shared by links() and the social preview.
|
|
129
|
+
const linksExport = findExport(program, "links");
|
|
130
|
+
if (!linksExport) return { ok: false, reason: "root.tsx has no links() export" };
|
|
131
|
+
const linksStatement = ((program.body as AstNode[]) ?? []).find(
|
|
132
|
+
(statement) => statement.start <= linksExport.start && statement.end >= linksExport.end,
|
|
133
|
+
);
|
|
134
|
+
s.appendLeft(linksStatement?.start ?? linksExport.start, SITE_ATTRIBUTES_DECL);
|
|
135
|
+
|
|
136
|
+
// 2. Favicon: drive the existing `rel: "icon"` entry from the config.
|
|
137
|
+
let faviconPatched = false;
|
|
138
|
+
walk(linksExport, (node) => {
|
|
139
|
+
if (faviconPatched || node.type !== "ObjectExpression") return;
|
|
140
|
+
const properties = (node.properties as AstNode[]) ?? [];
|
|
141
|
+
const rel = properties.find((prop) => propertyName(prop) === "rel");
|
|
142
|
+
const relValue = rel?.value as AstNode | undefined;
|
|
143
|
+
if (relValue?.type !== "Literal" || relValue.value !== "icon") return;
|
|
144
|
+
const href = properties.find((prop) => propertyName(prop) === "href");
|
|
145
|
+
const hrefValue = href?.value as AstNode | undefined;
|
|
146
|
+
if (hrefValue?.type !== "Literal" || typeof hrefValue.value !== "string") return;
|
|
147
|
+
s.overwrite(hrefValue.start, hrefValue.end, `siteAttributes.favicon ?? "${hrefValue.value}"`);
|
|
148
|
+
faviconPatched = true;
|
|
149
|
+
});
|
|
150
|
+
if (!faviconPatched) return { ok: false, reason: "root.tsx has no favicon link to update" };
|
|
151
|
+
|
|
152
|
+
// 3. The loader must expose the request origin: social crawlers need absolute image URLs.
|
|
153
|
+
const loader = findExport(program, "loader");
|
|
154
|
+
if (!loader) return { ok: false, reason: "root.tsx has no loader" };
|
|
155
|
+
const loaderParam = ((loader.params as AstNode[]) ?? [])[0];
|
|
156
|
+
if (loaderParam?.type !== "ObjectPattern") {
|
|
157
|
+
return { ok: false, reason: "root.tsx's loader does not destructure its arguments" };
|
|
158
|
+
}
|
|
159
|
+
addToObjectPattern(s, code, loaderParam, "request");
|
|
160
|
+
|
|
161
|
+
let originAdded = false;
|
|
162
|
+
walk(loader, (node) => {
|
|
163
|
+
if (originAdded || node.type !== "ReturnStatement") return;
|
|
164
|
+
let returned = node.argument as AstNode | null;
|
|
165
|
+
// The template returns `data({ … }, { headers })`; a plain object works too.
|
|
166
|
+
if (returned?.type === "CallExpression") {
|
|
167
|
+
returned = ((returned.arguments as AstNode[]) ?? [])[0] ?? null;
|
|
168
|
+
}
|
|
169
|
+
if (returned?.type !== "ObjectExpression") return;
|
|
170
|
+
const properties = (returned.properties as AstNode[]) ?? [];
|
|
171
|
+
const last = properties[properties.length - 1];
|
|
172
|
+
if (!last) return;
|
|
173
|
+
s.appendLeft(
|
|
174
|
+
last.end,
|
|
175
|
+
",\n // Social crawlers need absolute image URLs, and only the request knows the host.\n" +
|
|
176
|
+
" origin: new URL(request.url).origin",
|
|
177
|
+
);
|
|
178
|
+
originAdded = true;
|
|
179
|
+
});
|
|
180
|
+
if (!originAdded) return { ok: false, reason: "root.tsx's loader does not return an object" };
|
|
181
|
+
|
|
182
|
+
// 4. Render the preview tags from the default export, which receives the loader data.
|
|
183
|
+
const app = findExport(program, "default");
|
|
184
|
+
if (!app || (app.type !== "FunctionDeclaration" && app.type !== "ArrowFunctionExpression")) {
|
|
185
|
+
return { ok: false, reason: "root.tsx has no default component" };
|
|
186
|
+
}
|
|
187
|
+
const appParam = ((app.params as AstNode[]) ?? [])[0];
|
|
188
|
+
if (appParam?.type !== "ObjectPattern") {
|
|
189
|
+
return { ok: false, reason: "root.tsx's component does not destructure its props" };
|
|
190
|
+
}
|
|
191
|
+
const loaderDataProp = ((appParam.properties as AstNode[]) ?? []).find(
|
|
192
|
+
(prop) => propertyName(prop) === "loaderData",
|
|
193
|
+
);
|
|
194
|
+
const loaderDataPattern = loaderDataProp?.value as AstNode | undefined;
|
|
195
|
+
if (loaderDataPattern?.type === "ObjectPattern") {
|
|
196
|
+
addToObjectPattern(s, code, loaderDataPattern, "origin");
|
|
197
|
+
} else if (loaderDataProp) {
|
|
198
|
+
return { ok: false, reason: "root.tsx's component does not destructure its loader data" };
|
|
199
|
+
} else {
|
|
200
|
+
addToObjectPattern(s, code, appParam, "loaderData");
|
|
201
|
+
return { ok: false, reason: "root.tsx's component does not receive loader data" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let wrapped = false;
|
|
205
|
+
walk(app.body as AstNode, (node) => {
|
|
206
|
+
if (wrapped || node.type !== "ReturnStatement") return;
|
|
207
|
+
const returned = node.argument as AstNode | null;
|
|
208
|
+
if (!returned) return;
|
|
209
|
+
// `return (` keeps the expression on the return's own line, so no semicolon is inserted.
|
|
210
|
+
s.appendLeft(returned.start, `(\n <>\n ${SOCIAL_META}\n `);
|
|
211
|
+
s.appendRight(returned.end, "\n </>\n )");
|
|
212
|
+
wrapped = true;
|
|
213
|
+
});
|
|
214
|
+
if (!wrapped) return { ok: false, reason: "root.tsx's component returns nothing" };
|
|
215
|
+
|
|
216
|
+
const next = s.toString();
|
|
217
|
+
try {
|
|
218
|
+
const check = parseSync(filePath, next, { sourceType: "module" });
|
|
219
|
+
if (check.errors.length > 0) {
|
|
220
|
+
return { ok: false, reason: "the upgraded root.tsx would not parse" };
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
return { ok: false, reason: "the upgraded root.tsx would not parse" };
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, code: next, changed: true };
|
|
226
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { ensureRootSiteMeta } from "../site-meta";
|
|
3
|
+
import { UpstartEditorAPI } from "../upstart-editor-api";
|
|
4
|
+
import fs from "fs/promises";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import os from "os";
|
|
7
|
+
|
|
8
|
+
/** The template's root.tsx, trimmed to the parts the upgrade touches. */
|
|
9
|
+
const ROOT = `import { Links, Meta, Outlet, Scripts, data } from "react-router";
|
|
10
|
+
import type { Route } from "./+types/root";
|
|
11
|
+
import siteConfig from "./config/site.json" with { type: "json" };
|
|
12
|
+
import type { SiteAttributes } from "@upstart.gg/sdk";
|
|
13
|
+
|
|
14
|
+
export const links = () => [
|
|
15
|
+
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
|
16
|
+
// favicon
|
|
17
|
+
{ rel: "icon", href: "/favicon.ico" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export async function loader({
|
|
21
|
+
context,
|
|
22
|
+
}: LoaderFunctionArgs<RouterContextProvider>) {
|
|
23
|
+
const env = toPublicEnv(context.get(envContext));
|
|
24
|
+
const locale = getLocale(context);
|
|
25
|
+
|
|
26
|
+
return data(
|
|
27
|
+
{
|
|
28
|
+
env,
|
|
29
|
+
locale,
|
|
30
|
+
},
|
|
31
|
+
{ headers: { "Set-Cookie": await localeCookie.serialize(locale) } },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function App({ loaderData: { locale } }: Route.ComponentProps) {
|
|
36
|
+
return <Outlet />;
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
describe("ensureRootSiteMeta", () => {
|
|
41
|
+
test("wires site.json into the favicon, the loader and the component", () => {
|
|
42
|
+
const result = ensureRootSiteMeta(ROOT, "app/root.tsx");
|
|
43
|
+
|
|
44
|
+
expect(result).toMatchObject({ ok: true, changed: true });
|
|
45
|
+
if (!result.ok) return;
|
|
46
|
+
expect(result.code).toContain("const siteAttributes = siteConfig as unknown as SiteAttributes;");
|
|
47
|
+
expect(result.code).toContain('{ rel: "icon", href: siteAttributes.favicon ?? "/favicon.ico" }');
|
|
48
|
+
expect(result.code).toContain("origin: new URL(request.url).origin");
|
|
49
|
+
expect(result.code).toContain("loaderData: { locale, origin }");
|
|
50
|
+
expect(result.code).toContain('<meta property="og:image"');
|
|
51
|
+
expect(result.code).toContain('<meta name="twitter:card" content="summary_large_image" />');
|
|
52
|
+
// The request must reach the loader for the origin to exist.
|
|
53
|
+
expect(result.code).toMatch(/loader\(\{\n\s*context,\n\s*request,\n\s*\}/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("is idempotent", () => {
|
|
57
|
+
const once = ensureRootSiteMeta(ROOT, "app/root.tsx");
|
|
58
|
+
expect(once.ok).toBe(true);
|
|
59
|
+
if (!once.ok) return;
|
|
60
|
+
const twice = ensureRootSiteMeta(once.code, "app/root.tsx");
|
|
61
|
+
expect(twice).toMatchObject({ ok: true, changed: false });
|
|
62
|
+
if (twice.ok) expect(twice.code).toBe(once.code);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("refuses a root that does not read the site configuration", () => {
|
|
66
|
+
const result = ensureRootSiteMeta(
|
|
67
|
+
`export default function App() {\n return <div />;\n}\n`,
|
|
68
|
+
"app/root.tsx",
|
|
69
|
+
);
|
|
70
|
+
expect(result).toMatchObject({ ok: false });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("refuses a root whose links() has no favicon entry", () => {
|
|
74
|
+
const result = ensureRootSiteMeta(
|
|
75
|
+
ROOT.replace(' { rel: "icon", href: "/favicon.ico" },\n', ""),
|
|
76
|
+
"app/root.tsx",
|
|
77
|
+
);
|
|
78
|
+
expect(result).toMatchObject({ ok: false, reason: expect.stringContaining("favicon") });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("UpstartEditorAPI site meta", () => {
|
|
83
|
+
let tempDir: string;
|
|
84
|
+
let api: UpstartEditorAPI;
|
|
85
|
+
|
|
86
|
+
beforeEach(async () => {
|
|
87
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "upstart-site-test-"));
|
|
88
|
+
api = new UpstartEditorAPI(tempDir, path.join(tempDir, "registry.json"));
|
|
89
|
+
await fs.mkdir(path.join(tempDir, "app", "config"), { recursive: true });
|
|
90
|
+
await fs.writeFile(path.join(tempDir, "app", "root.tsx"), ROOT);
|
|
91
|
+
await fs.writeFile(
|
|
92
|
+
path.join(tempDir, "app", "config", "site.json"),
|
|
93
|
+
`${JSON.stringify({ label: "My site", defaultLanguage: "en" }, null, 2)}\n`,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(async () => {
|
|
98
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const readConfig = async () =>
|
|
102
|
+
JSON.parse(await fs.readFile(path.join(tempDir, "app", "config", "site.json"), "utf-8"));
|
|
103
|
+
|
|
104
|
+
test("reports the current settings and that the site can be upgraded", async () => {
|
|
105
|
+
const result = await api.getSiteMeta({ action: "getSiteMeta" });
|
|
106
|
+
expect(result).toMatchObject({ success: true, favicon: null, socialImage: null, editable: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("saves the paths and upgrades root.tsx once", async () => {
|
|
110
|
+
const first = await api.setSiteMeta({
|
|
111
|
+
action: "setSiteMeta",
|
|
112
|
+
socialImage: "/images/preview.webp",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(first.success).toBe(true);
|
|
116
|
+
if (!first.success) return;
|
|
117
|
+
// root.tsx + site.json
|
|
118
|
+
expect(first.filePaths).toHaveLength(2);
|
|
119
|
+
expect(await readConfig()).toMatchObject({ socialImage: "/images/preview.webp", label: "My site" });
|
|
120
|
+
|
|
121
|
+
const second = await api.setSiteMeta({ action: "setSiteMeta", favicon: "/images/icon.png" });
|
|
122
|
+
expect(second.success).toBe(true);
|
|
123
|
+
// root.tsx is already up to date, so only site.json changes this time.
|
|
124
|
+
if (second.success) expect(second.filePaths).toHaveLength(1);
|
|
125
|
+
expect(await readConfig()).toMatchObject({
|
|
126
|
+
socialImage: "/images/preview.webp",
|
|
127
|
+
favicon: "/images/icon.png",
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("clears a setting when passed null and leaves the other one alone", async () => {
|
|
132
|
+
await api.setSiteMeta({
|
|
133
|
+
action: "setSiteMeta",
|
|
134
|
+
favicon: "/images/icon.png",
|
|
135
|
+
socialImage: "/images/p.webp",
|
|
136
|
+
});
|
|
137
|
+
await api.setSiteMeta({ action: "setSiteMeta", favicon: null });
|
|
138
|
+
|
|
139
|
+
const config = await readConfig();
|
|
140
|
+
expect(config.favicon).toBeUndefined();
|
|
141
|
+
expect(config.socialImage).toBe("/images/p.webp");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("rejects paths that escape the public folder", async () => {
|
|
145
|
+
const result = await api.setSiteMeta({ action: "setSiteMeta", favicon: "/../../etc/passwd" });
|
|
146
|
+
expect(result).toMatchObject({ success: false });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("refuses to save when root.tsx cannot be upgraded", async () => {
|
|
150
|
+
await fs.writeFile(
|
|
151
|
+
path.join(tempDir, "app", "root.tsx"),
|
|
152
|
+
`export default function App() {\n return <div />;\n}\n`,
|
|
153
|
+
);
|
|
154
|
+
const result = await api.setSiteMeta({ action: "setSiteMeta", favicon: "/images/icon.png" });
|
|
155
|
+
expect(result).toMatchObject({ success: false });
|
|
156
|
+
expect(await readConfig()).not.toHaveProperty("favicon");
|
|
157
|
+
});
|
|
158
|
+
});
|