@upstart.gg/vite-plugins 0.1.61 → 0.1.63
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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { analyzeRouteMeta } from "./page-meta.js";
|
|
2
|
+
import { ensureRootSiteMeta } from "./site-meta.js";
|
|
1
3
|
import MagicString from "magic-string";
|
|
2
4
|
import { parseSync } from "oxc-parser";
|
|
3
5
|
import fs from "node:fs/promises";
|
|
@@ -57,6 +59,213 @@ function inferArrayQuote(code, elements) {
|
|
|
57
59
|
}
|
|
58
60
|
return "\"";
|
|
59
61
|
}
|
|
62
|
+
/** Read the ArrayExpression a `meta` export resolves to, or explain why we can't. */
|
|
63
|
+
function readMetaArray(node) {
|
|
64
|
+
if (node.type === "ArrayExpression") return {
|
|
65
|
+
ok: true,
|
|
66
|
+
array: node
|
|
67
|
+
};
|
|
68
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
|
|
69
|
+
const body = node.body;
|
|
70
|
+
if (body.type === "ArrayExpression") return {
|
|
71
|
+
ok: true,
|
|
72
|
+
array: body
|
|
73
|
+
};
|
|
74
|
+
if (body.type === "BlockStatement") {
|
|
75
|
+
const argument = (body.body ?? []).find((st) => st.type === "ReturnStatement")?.argument;
|
|
76
|
+
if (argument?.type === "ArrayExpression") return {
|
|
77
|
+
ok: true,
|
|
78
|
+
array: argument
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: "The page meta is computed by code"
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Turn a static array of object literals into plain key/value entries, or null if any part is dynamic. */
|
|
88
|
+
function readStaticEntries(elements) {
|
|
89
|
+
const entries = [];
|
|
90
|
+
for (const element of elements) {
|
|
91
|
+
if (!element || element.type !== "ObjectExpression") return null;
|
|
92
|
+
const entry = [];
|
|
93
|
+
for (const prop of element.properties ?? []) {
|
|
94
|
+
if (prop.type !== "Property" || prop.computed || prop.shorthand || prop.kind !== "init") return null;
|
|
95
|
+
const key = prop.key;
|
|
96
|
+
const value = prop.value;
|
|
97
|
+
const keyName = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : null;
|
|
98
|
+
if (keyName === null) return null;
|
|
99
|
+
if (value.type !== "Literal" || typeof value.value !== "string") return null;
|
|
100
|
+
entry.push({
|
|
101
|
+
key: keyName,
|
|
102
|
+
value: value.value
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
entries.push(entry);
|
|
106
|
+
}
|
|
107
|
+
return entries;
|
|
108
|
+
}
|
|
109
|
+
/** Locate the route's `meta` export and read it if it is fully static. */
|
|
110
|
+
function findMetaExport(code, filePath) {
|
|
111
|
+
const body = parseSync(filePath, code, { sourceType: "module" }).program?.body ?? [];
|
|
112
|
+
for (const statement of body) {
|
|
113
|
+
if (statement.type !== "ExportNamedDeclaration") continue;
|
|
114
|
+
const declaration = statement.declaration;
|
|
115
|
+
if (!declaration) {
|
|
116
|
+
if ((statement.specifiers ?? []).some((spec) => {
|
|
117
|
+
const name = spec.exported;
|
|
118
|
+
return name?.type === "Identifier" && name.name === "meta";
|
|
119
|
+
})) return {
|
|
120
|
+
kind: "dynamic",
|
|
121
|
+
reason: "The page meta is exported indirectly"
|
|
122
|
+
};
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let source = null;
|
|
126
|
+
if (declaration.type === "VariableDeclaration") for (const decl of declaration.declarations ?? []) {
|
|
127
|
+
const id = decl.id;
|
|
128
|
+
if (id.type === "Identifier" && id.name === "meta") source = decl.init ?? null;
|
|
129
|
+
}
|
|
130
|
+
else if (declaration.type === "FunctionDeclaration") {
|
|
131
|
+
const id = declaration.id;
|
|
132
|
+
if (id?.type === "Identifier" && id.name === "meta") source = declaration;
|
|
133
|
+
}
|
|
134
|
+
if (!source) continue;
|
|
135
|
+
const array = readMetaArray(source);
|
|
136
|
+
if (!array.ok) return {
|
|
137
|
+
kind: "dynamic",
|
|
138
|
+
reason: array.reason
|
|
139
|
+
};
|
|
140
|
+
const entries = readStaticEntries(array.array.elements ?? []);
|
|
141
|
+
if (!entries) return {
|
|
142
|
+
kind: "dynamic",
|
|
143
|
+
reason: "The page meta contains dynamic values"
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
kind: "static",
|
|
147
|
+
arrayStart: array.array.start,
|
|
148
|
+
arrayEnd: array.array.end,
|
|
149
|
+
entries
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return { kind: "none" };
|
|
153
|
+
}
|
|
154
|
+
/** Offset right after the last top-level import, or the end of the file when there is none. */
|
|
155
|
+
function findMetaInsertOffset(code, filePath) {
|
|
156
|
+
const body = parseSync(filePath, code, { sourceType: "module" }).program?.body ?? [];
|
|
157
|
+
let offset = null;
|
|
158
|
+
for (const statement of body) if (statement.type === "ImportDeclaration") offset = statement.end;
|
|
159
|
+
return offset ?? code.length;
|
|
160
|
+
}
|
|
161
|
+
/** Serialize entries back to source, one object literal per line. */
|
|
162
|
+
function printMetaArray(entries, indent) {
|
|
163
|
+
if (entries.length === 0) return "[]";
|
|
164
|
+
return `[\n${entries.map((entry) => {
|
|
165
|
+
return `${indent} { ${entry.map(({ key, value }) => {
|
|
166
|
+
return `${/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : `"${escapeStringLiteralBody(key, "\"")}"`}: "${escapeStringLiteralBody(value, "\"")}"`;
|
|
167
|
+
}).join(", ")} },`;
|
|
168
|
+
}).join("\n")}\n${indent}]`;
|
|
169
|
+
}
|
|
170
|
+
/** Read the value of `{ name: <name>, content: X }` (or `{ title: X }` when name is "title"). */
|
|
171
|
+
function findMetaValue(entries, name) {
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
if (name === "title") {
|
|
174
|
+
const title = entry.find((p) => p.key === "title");
|
|
175
|
+
if (title && entry.length === 1) return title.value;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (entry.some((p) => p.key === "name" && p.value === name)) return entry.find((p) => p.key === "content")?.value ?? null;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Update/insert/remove the entry for `name`, leaving every other entry (og:*, twitter:*,
|
|
184
|
+
* canonical…) untouched and in place. An empty `value` removes the entry.
|
|
185
|
+
*/
|
|
186
|
+
function upsertMetaValue(entries, name, value) {
|
|
187
|
+
const matches = (entry) => name === "title" ? entry.length === 1 && entry[0].key === "title" : entry.some((p) => p.key === "name" && p.value === name);
|
|
188
|
+
const index = entries.findIndex(matches);
|
|
189
|
+
const next = entries.filter((entry, i) => i === index || !matches(entry));
|
|
190
|
+
if (value === "") return next.filter((entry) => !matches(entry));
|
|
191
|
+
const entry = name === "title" ? [{
|
|
192
|
+
key: "title",
|
|
193
|
+
value
|
|
194
|
+
}] : [{
|
|
195
|
+
key: "name",
|
|
196
|
+
value: name
|
|
197
|
+
}, {
|
|
198
|
+
key: "content",
|
|
199
|
+
value
|
|
200
|
+
}];
|
|
201
|
+
if (index === -1) return name === "title" ? [entry, ...next] : [...next, entry];
|
|
202
|
+
return next.map((existing, i) => i === index ? entry : existing);
|
|
203
|
+
}
|
|
204
|
+
/** Number of times a translation key appears in the app sources. */
|
|
205
|
+
function countKeyUsages(sources, key) {
|
|
206
|
+
let count = 0;
|
|
207
|
+
let index = sources.indexOf(key);
|
|
208
|
+
while (index !== -1) {
|
|
209
|
+
const before = sources[index - 1];
|
|
210
|
+
const after = sources[index + key.length];
|
|
211
|
+
const isBoundary = (char) => char === void 0 || char === "\"" || char === "'" || char === "`" || char === ":";
|
|
212
|
+
if (isBoundary(before) && isBoundary(after)) count++;
|
|
213
|
+
index = sources.indexOf(key, index + key.length);
|
|
214
|
+
}
|
|
215
|
+
return count;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Read a key from a locale document. i18next accepts both a flat key ("a.b" as a literal
|
|
219
|
+
* property) and a nested path, so both are tried — flat first, as the generated sites use it.
|
|
220
|
+
*/
|
|
221
|
+
function readLocaleValue(data, key) {
|
|
222
|
+
const flat = data[key];
|
|
223
|
+
if (typeof flat === "string") return flat;
|
|
224
|
+
let current = data;
|
|
225
|
+
for (const part of key.split(".")) {
|
|
226
|
+
if (!current || typeof current !== "object") return null;
|
|
227
|
+
current = current[part];
|
|
228
|
+
}
|
|
229
|
+
return typeof current === "string" ? current : null;
|
|
230
|
+
}
|
|
231
|
+
/** Write a key, keeping the shape it already has; new keys are created flat. */
|
|
232
|
+
function writeLocaleValue(data, key, value) {
|
|
233
|
+
if (typeof data[key] === "string") {
|
|
234
|
+
data[key] = value;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const parts = key.split(".");
|
|
238
|
+
let current = data;
|
|
239
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
240
|
+
const next = current[parts[i]];
|
|
241
|
+
if (!next || typeof next !== "object") {
|
|
242
|
+
data[key] = value;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
current = next;
|
|
246
|
+
}
|
|
247
|
+
const leaf = parts[parts.length - 1];
|
|
248
|
+
if (typeof current[leaf] === "string") {
|
|
249
|
+
current[leaf] = value;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
data[key] = value;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Render a meta tag to insert in a route that doesn't have one yet. Values that JSX can hold
|
|
256
|
+
* verbatim are written as plain text/attributes; anything else goes through an expression
|
|
257
|
+
* container so quotes and braces survive.
|
|
258
|
+
*/
|
|
259
|
+
function printMetaTag(fieldName, value) {
|
|
260
|
+
if (fieldName === "title") return !/[{}<>&\n]/.test(value) ? `<title>${value}</title>` : `<title>{"${escapeStringLiteralBody(value, "\"")}"}</title>`;
|
|
261
|
+
return `<meta name="${fieldName}" ${/["\n]/.test(value) ? `content={"${escapeStringLiteralBody(value, "\"")}"}` : `content="${value}"`} />`;
|
|
262
|
+
}
|
|
263
|
+
/** Start of the line `offset` sits on, so a removed tag doesn't leave a blank line behind. */
|
|
264
|
+
function lineRangeStart(code, offset) {
|
|
265
|
+
const lineStart = code.lastIndexOf("\n", offset);
|
|
266
|
+
if (lineStart === -1) return offset;
|
|
267
|
+
return code.slice(lineStart + 1, offset).trim() === "" ? lineStart : offset;
|
|
268
|
+
}
|
|
60
269
|
const payloadEditText = z.object({
|
|
61
270
|
action: z.literal("editText"),
|
|
62
271
|
language: z.string().length(2).regex(/^[a-z]{2}$/),
|
|
@@ -94,6 +303,29 @@ const payloadArraySet = z.object({
|
|
|
94
303
|
arrayId: z.string().min(1),
|
|
95
304
|
items: z.array(z.string()).min(1)
|
|
96
305
|
});
|
|
306
|
+
const routeIdSchema = z.string().min(1).regex(/^[a-zA-Z0-9._$/()[\]+~-]+$/).refine((id) => !id.split("/").includes("..") && !id.startsWith("/"), "Invalid route id");
|
|
307
|
+
const payloadGetPageMeta = z.object({
|
|
308
|
+
action: z.literal("getPageMeta"),
|
|
309
|
+
routeId: routeIdSchema,
|
|
310
|
+
/** Locale to read the translated values in. Defaults to the site's default language. */
|
|
311
|
+
language: z.string().regex(/^[a-zA-Z-]{2,10}$/).optional()
|
|
312
|
+
});
|
|
313
|
+
const payloadSetPageMeta = z.object({
|
|
314
|
+
action: z.literal("setPageMeta"),
|
|
315
|
+
routeId: routeIdSchema,
|
|
316
|
+
language: z.string().regex(/^[a-zA-Z-]{2,10}$/).optional(),
|
|
317
|
+
title: z.string().default(""),
|
|
318
|
+
description: z.string().default(""),
|
|
319
|
+
keywords: z.string().default(""),
|
|
320
|
+
robotsIndexing: z.boolean().default(true)
|
|
321
|
+
});
|
|
322
|
+
const payloadGetSiteMeta = z.object({ action: z.literal("getSiteMeta") });
|
|
323
|
+
const publicImagePath = z.string().regex(/^\/[A-Za-z0-9._\-/]+$/).refine((value) => !value.includes(".."), "Invalid image path");
|
|
324
|
+
const payloadSetSiteMeta = z.object({
|
|
325
|
+
action: z.literal("setSiteMeta"),
|
|
326
|
+
favicon: publicImagePath.nullable().optional(),
|
|
327
|
+
socialImage: publicImagePath.nullable().optional()
|
|
328
|
+
});
|
|
97
329
|
var UpstartEditorAPI = class {
|
|
98
330
|
registry = null;
|
|
99
331
|
projectRoot;
|
|
@@ -425,6 +657,529 @@ var UpstartEditorAPI = class {
|
|
|
425
657
|
};
|
|
426
658
|
}
|
|
427
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* Read the page metadata shown in the browser tab and in search results.
|
|
662
|
+
*
|
|
663
|
+
* Two shapes are supported, in this order: React-rendered tags (`<title>{title}</title>`
|
|
664
|
+
* fed by the loader, the shape the AI assistant generates — the text then lives in the
|
|
665
|
+
* locale files), and a static `export const meta` array.
|
|
666
|
+
*/
|
|
667
|
+
async getPageMeta(params) {
|
|
668
|
+
const parsed = payloadGetPageMeta.safeParse(params);
|
|
669
|
+
if (!parsed.success) return {
|
|
670
|
+
success: false,
|
|
671
|
+
error: `Invalid payload: ${parsed.error.message}`
|
|
672
|
+
};
|
|
673
|
+
const route = await this.resolveRouteFile(parsed.data.routeId);
|
|
674
|
+
if (!route) return {
|
|
675
|
+
success: false,
|
|
676
|
+
error: `Route file not found for "${parsed.data.routeId}"`
|
|
677
|
+
};
|
|
678
|
+
let code;
|
|
679
|
+
try {
|
|
680
|
+
code = await fs.readFile(route.filePath, "utf-8");
|
|
681
|
+
} catch {
|
|
682
|
+
return {
|
|
683
|
+
success: false,
|
|
684
|
+
error: `Failed to read route file: ${route.relativeFile}`
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
const languages = await this.listLanguages();
|
|
688
|
+
const language = await this.resolveLanguage(parsed.data.language, languages);
|
|
689
|
+
const files = await this.readAppSources();
|
|
690
|
+
const analysis = analyzeRouteMeta(code, route.relativeFile, this.createModuleLoader(files, route.filePath));
|
|
691
|
+
if (analysis.hasJsxMeta) {
|
|
692
|
+
const sources = [...files.values()].join("\n");
|
|
693
|
+
const [title, description, keywords] = await Promise.all([
|
|
694
|
+
this.describeField(analysis.title, language, sources),
|
|
695
|
+
this.describeField(analysis.description, language, sources),
|
|
696
|
+
this.describeField(analysis.keywords, language, sources)
|
|
697
|
+
]);
|
|
698
|
+
const robots = analysis.robots.origin;
|
|
699
|
+
const robotsEditable = robots.kind === "absent" || robots.kind === "literal";
|
|
700
|
+
return {
|
|
701
|
+
success: true,
|
|
702
|
+
...route,
|
|
703
|
+
mode: "jsx",
|
|
704
|
+
languages,
|
|
705
|
+
language,
|
|
706
|
+
title,
|
|
707
|
+
description,
|
|
708
|
+
keywords,
|
|
709
|
+
robotsIndexing: robots.kind === "literal" ? !/noindex/i.test(robots.value) : true,
|
|
710
|
+
robotsEditable,
|
|
711
|
+
...robotsEditable ? {} : { robotsReason: "This tag is computed by code" }
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
const plain = (value, editable, reason) => ({
|
|
715
|
+
value,
|
|
716
|
+
editable,
|
|
717
|
+
...reason ? { reason } : {},
|
|
718
|
+
translated: false,
|
|
719
|
+
shared: false,
|
|
720
|
+
prefix: "",
|
|
721
|
+
suffix: ""
|
|
722
|
+
});
|
|
723
|
+
const lookup = findMetaExport(code, route.relativeFile);
|
|
724
|
+
if (lookup.kind === "dynamic") return {
|
|
725
|
+
success: true,
|
|
726
|
+
...route,
|
|
727
|
+
mode: "meta-export",
|
|
728
|
+
languages,
|
|
729
|
+
language,
|
|
730
|
+
title: plain("", false, lookup.reason),
|
|
731
|
+
description: plain("", false, lookup.reason),
|
|
732
|
+
keywords: plain("", false, lookup.reason),
|
|
733
|
+
robotsIndexing: true,
|
|
734
|
+
robotsEditable: false,
|
|
735
|
+
robotsReason: lookup.reason
|
|
736
|
+
};
|
|
737
|
+
const entries = lookup.kind === "static" ? lookup.entries : [];
|
|
738
|
+
const robotsValue = findMetaValue(entries, "robots");
|
|
739
|
+
return {
|
|
740
|
+
success: true,
|
|
741
|
+
...route,
|
|
742
|
+
mode: "meta-export",
|
|
743
|
+
languages,
|
|
744
|
+
language,
|
|
745
|
+
title: plain(findMetaValue(entries, "title") ?? "", true),
|
|
746
|
+
description: plain(findMetaValue(entries, "description") ?? "", true),
|
|
747
|
+
keywords: plain(findMetaValue(entries, "keywords") ?? "", true),
|
|
748
|
+
robotsIndexing: robotsValue === null ? true : !/noindex/i.test(robotsValue),
|
|
749
|
+
robotsEditable: true
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Write the page metadata back. Values backed by a translation key are written to the
|
|
754
|
+
* locale file of `language`; everything else is written into the route source. Returns
|
|
755
|
+
* every file that changed so the caller can commit them together.
|
|
756
|
+
*/
|
|
757
|
+
async setPageMeta(params) {
|
|
758
|
+
const parsed = payloadSetPageMeta.safeParse(params);
|
|
759
|
+
if (!parsed.success) return {
|
|
760
|
+
success: false,
|
|
761
|
+
error: `Invalid payload: ${parsed.error.message}`
|
|
762
|
+
};
|
|
763
|
+
const { routeId, title, description, keywords, robotsIndexing } = parsed.data;
|
|
764
|
+
const route = await this.resolveRouteFile(routeId);
|
|
765
|
+
if (!route) return {
|
|
766
|
+
success: false,
|
|
767
|
+
error: `Route file not found for "${routeId}"`
|
|
768
|
+
};
|
|
769
|
+
try {
|
|
770
|
+
const code = await fs.readFile(route.filePath, "utf-8");
|
|
771
|
+
const languages = await this.listLanguages();
|
|
772
|
+
const language = await this.resolveLanguage(parsed.data.language, languages);
|
|
773
|
+
const files = await this.readAppSources();
|
|
774
|
+
const analysis = analyzeRouteMeta(code, route.relativeFile, this.createModuleLoader(files, route.filePath));
|
|
775
|
+
if (!analysis.hasJsxMeta) return this.setMetaExport(route, code, {
|
|
776
|
+
title,
|
|
777
|
+
description,
|
|
778
|
+
keywords,
|
|
779
|
+
robotsIndexing
|
|
780
|
+
});
|
|
781
|
+
const sources = [...files.values()].join("\n");
|
|
782
|
+
const s = new MagicString(code);
|
|
783
|
+
const locales = /* @__PURE__ */ new Map();
|
|
784
|
+
const changedLocales = /* @__PURE__ */ new Set();
|
|
785
|
+
let sourceChanged = false;
|
|
786
|
+
const loadLocale = async (lang, namespace) => {
|
|
787
|
+
const id = `${lang}/${namespace}`;
|
|
788
|
+
const cached = locales.get(id);
|
|
789
|
+
if (cached) return cached;
|
|
790
|
+
const data = await this.readLocale(lang, namespace);
|
|
791
|
+
locales.set(id, data);
|
|
792
|
+
return data;
|
|
793
|
+
};
|
|
794
|
+
const applyField = async (element, next, fieldName) => {
|
|
795
|
+
const { origin } = element;
|
|
796
|
+
if (origin.kind === "unsupported" || origin.kind === "prop") return;
|
|
797
|
+
if (origin.kind === "absent") {
|
|
798
|
+
if (next === "" || analysis.insertOffset === null) return;
|
|
799
|
+
s.appendLeft(analysis.insertOffset, `\n${analysis.insertIndent}${printMetaTag(fieldName, next)}`);
|
|
800
|
+
sourceChanged = true;
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (origin.kind === "literal") {
|
|
804
|
+
if (origin.value === next) return;
|
|
805
|
+
s.overwrite(origin.start, origin.end, escapeStringLiteralBody(next, origin.quote));
|
|
806
|
+
sourceChanged = true;
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const data = await loadLocale(language, origin.namespace);
|
|
810
|
+
if ((readLocaleValue(data, origin.key) ?? "") === next) return;
|
|
811
|
+
if (countKeyUsages(sources, origin.key) > 1) {
|
|
812
|
+
const newKey = await this.allocateMetaKey(origin.key, fieldName, sources, origin.namespace);
|
|
813
|
+
for (const lang of languages) {
|
|
814
|
+
const langData = await loadLocale(lang, origin.namespace);
|
|
815
|
+
const previous = readLocaleValue(langData, origin.key);
|
|
816
|
+
if (previous === null) continue;
|
|
817
|
+
writeLocaleValue(langData, newKey, previous);
|
|
818
|
+
changedLocales.add(`${lang}/${origin.namespace}`);
|
|
819
|
+
}
|
|
820
|
+
const raw = code.slice(origin.keyStart, origin.keyEnd);
|
|
821
|
+
const prefix = raw.includes(":") ? `${raw.slice(0, raw.indexOf(":") + 1)}` : "";
|
|
822
|
+
s.overwrite(origin.keyStart, origin.keyEnd, `${prefix}${newKey}`);
|
|
823
|
+
sourceChanged = true;
|
|
824
|
+
writeLocaleValue(await loadLocale(language, origin.namespace), newKey, next);
|
|
825
|
+
} else writeLocaleValue(data, origin.key, next);
|
|
826
|
+
changedLocales.add(`${language}/${origin.namespace}`);
|
|
827
|
+
};
|
|
828
|
+
await applyField(analysis.title, title, "title");
|
|
829
|
+
await applyField(analysis.description, description, "description");
|
|
830
|
+
await applyField(analysis.keywords, keywords, "keywords");
|
|
831
|
+
const robots = analysis.robots;
|
|
832
|
+
if (robotsIndexing) {
|
|
833
|
+
if (robots.origin.kind === "literal" && robots.elementStart !== void 0) {
|
|
834
|
+
s.remove(lineRangeStart(code, robots.elementStart), robots.elementEnd);
|
|
835
|
+
sourceChanged = true;
|
|
836
|
+
}
|
|
837
|
+
} else if (robots.origin.kind === "literal") {
|
|
838
|
+
if (!/noindex/i.test(robots.origin.value)) {
|
|
839
|
+
s.overwrite(robots.origin.start, robots.origin.end, "noindex, nofollow");
|
|
840
|
+
sourceChanged = true;
|
|
841
|
+
}
|
|
842
|
+
} else if (robots.origin.kind === "absent" && analysis.insertOffset !== null) {
|
|
843
|
+
s.appendLeft(analysis.insertOffset, `\n${analysis.insertIndent}${printMetaTag("robots", "noindex, nofollow")}`);
|
|
844
|
+
sourceChanged = true;
|
|
845
|
+
}
|
|
846
|
+
const filePaths = [];
|
|
847
|
+
if (sourceChanged) {
|
|
848
|
+
await fs.writeFile(route.filePath, s.toString());
|
|
849
|
+
filePaths.push(route.filePath);
|
|
850
|
+
this.registry = null;
|
|
851
|
+
}
|
|
852
|
+
for (const id of changedLocales) {
|
|
853
|
+
const [lang, namespace] = id.split("/");
|
|
854
|
+
const data = locales.get(id);
|
|
855
|
+
if (!data) continue;
|
|
856
|
+
const localePath = this.localePath(lang, namespace);
|
|
857
|
+
await fs.writeFile(localePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
858
|
+
filePaths.push(localePath);
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
success: true,
|
|
862
|
+
filePaths
|
|
863
|
+
};
|
|
864
|
+
} catch (err) {
|
|
865
|
+
return {
|
|
866
|
+
success: false,
|
|
867
|
+
error: String(err)
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Read the site-wide settings rendered by `app/root.tsx`: the browser-tab icon and the
|
|
873
|
+
* image shown when a page is shared on social networks.
|
|
874
|
+
*/
|
|
875
|
+
async getSiteMeta(params) {
|
|
876
|
+
const parsed = payloadGetSiteMeta.safeParse(params);
|
|
877
|
+
if (!parsed.success) return {
|
|
878
|
+
success: false,
|
|
879
|
+
error: `Invalid payload: ${parsed.error.message}`
|
|
880
|
+
};
|
|
881
|
+
const config = await this.readSiteConfig();
|
|
882
|
+
if (!config) return {
|
|
883
|
+
success: false,
|
|
884
|
+
error: "This site has no app/config/site.json"
|
|
885
|
+
};
|
|
886
|
+
const root = await this.readRootFile();
|
|
887
|
+
const upgrade = root ? ensureRootSiteMeta(root.code, root.relativeFile) : null;
|
|
888
|
+
return {
|
|
889
|
+
success: true,
|
|
890
|
+
favicon: typeof config.favicon === "string" ? config.favicon : null,
|
|
891
|
+
socialImage: typeof config.socialImage === "string" ? config.socialImage : null,
|
|
892
|
+
editable: !!upgrade?.ok,
|
|
893
|
+
...upgrade && !upgrade.ok ? { reason: upgrade.reason } : {},
|
|
894
|
+
...root ? {} : { reason: "This site has no app/root.tsx" }
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Write the site-wide settings to `app/config/site.json`, upgrading `app/root.tsx` to
|
|
899
|
+
* render them if it does not already. The image files themselves are copied into the
|
|
900
|
+
* workspace by the caller, which has bucket access.
|
|
901
|
+
*/
|
|
902
|
+
async setSiteMeta(params) {
|
|
903
|
+
const parsed = payloadSetSiteMeta.safeParse(params);
|
|
904
|
+
if (!parsed.success) return {
|
|
905
|
+
success: false,
|
|
906
|
+
error: `Invalid payload: ${parsed.error.message}`
|
|
907
|
+
};
|
|
908
|
+
const { favicon, socialImage } = parsed.data;
|
|
909
|
+
if (favicon === void 0 && socialImage === void 0) return {
|
|
910
|
+
success: true,
|
|
911
|
+
filePaths: []
|
|
912
|
+
};
|
|
913
|
+
try {
|
|
914
|
+
const config = await this.readSiteConfig();
|
|
915
|
+
if (!config) return {
|
|
916
|
+
success: false,
|
|
917
|
+
error: "This site has no app/config/site.json"
|
|
918
|
+
};
|
|
919
|
+
const filePaths = [];
|
|
920
|
+
const root = await this.readRootFile();
|
|
921
|
+
if (!root) return {
|
|
922
|
+
success: false,
|
|
923
|
+
error: "This site has no app/root.tsx"
|
|
924
|
+
};
|
|
925
|
+
const upgrade = ensureRootSiteMeta(root.code, root.relativeFile);
|
|
926
|
+
if (!upgrade.ok) return {
|
|
927
|
+
success: false,
|
|
928
|
+
error: `${upgrade.reason} — ask Upsie to update it`
|
|
929
|
+
};
|
|
930
|
+
if (upgrade.changed) {
|
|
931
|
+
await fs.writeFile(root.filePath, upgrade.code);
|
|
932
|
+
filePaths.push(root.filePath);
|
|
933
|
+
this.registry = null;
|
|
934
|
+
}
|
|
935
|
+
const next = { ...config };
|
|
936
|
+
const apply = (key, value) => {
|
|
937
|
+
if (value === void 0) return;
|
|
938
|
+
if (value === null) delete next[key];
|
|
939
|
+
else next[key] = value;
|
|
940
|
+
};
|
|
941
|
+
apply("favicon", favicon);
|
|
942
|
+
apply("socialImage", socialImage);
|
|
943
|
+
const configPath = path.join(this.projectRoot, "app", "config", "site.json");
|
|
944
|
+
await fs.writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
945
|
+
filePaths.push(configPath);
|
|
946
|
+
return {
|
|
947
|
+
success: true,
|
|
948
|
+
filePaths
|
|
949
|
+
};
|
|
950
|
+
} catch (err) {
|
|
951
|
+
return {
|
|
952
|
+
success: false,
|
|
953
|
+
error: String(err)
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
async readSiteConfig() {
|
|
958
|
+
try {
|
|
959
|
+
const raw = await fs.readFile(path.join(this.projectRoot, "app", "config", "site.json"), "utf-8");
|
|
960
|
+
const parsed = JSON.parse(raw);
|
|
961
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
962
|
+
} catch {
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
async readRootFile() {
|
|
967
|
+
for (const ext of ["tsx", "jsx"]) {
|
|
968
|
+
const filePath = path.join(this.projectRoot, "app", `root.${ext}`);
|
|
969
|
+
try {
|
|
970
|
+
return {
|
|
971
|
+
filePath,
|
|
972
|
+
relativeFile: path.relative(this.projectRoot, filePath),
|
|
973
|
+
code: await fs.readFile(filePath, "utf-8")
|
|
974
|
+
};
|
|
975
|
+
} catch {}
|
|
976
|
+
}
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
/** Rewrite a static `export const meta` array (routes that don't render meta as JSX). */
|
|
980
|
+
async setMetaExport(route, code, values) {
|
|
981
|
+
const lookup = findMetaExport(code, route.relativeFile);
|
|
982
|
+
if (lookup.kind === "dynamic") return {
|
|
983
|
+
success: false,
|
|
984
|
+
error: `${lookup.reason} and cannot be edited here`
|
|
985
|
+
};
|
|
986
|
+
let entries = lookup.kind === "static" ? lookup.entries : [];
|
|
987
|
+
entries = upsertMetaValue(entries, "title", values.title);
|
|
988
|
+
entries = upsertMetaValue(entries, "description", values.description);
|
|
989
|
+
entries = upsertMetaValue(entries, "keywords", values.keywords);
|
|
990
|
+
entries = upsertMetaValue(entries, "robots", values.robotsIndexing ? "" : "noindex, nofollow");
|
|
991
|
+
const s = new MagicString(code);
|
|
992
|
+
if (lookup.kind === "static") {
|
|
993
|
+
const lineStart = code.lastIndexOf("\n", lookup.arrayStart) + 1;
|
|
994
|
+
const indent = code.slice(lineStart, lookup.arrayStart).match(/^[ \t]*/)?.[0] ?? "";
|
|
995
|
+
s.overwrite(lookup.arrayStart, lookup.arrayEnd, printMetaArray(entries, indent));
|
|
996
|
+
} else {
|
|
997
|
+
const insertAt = findMetaInsertOffset(code, route.relativeFile);
|
|
998
|
+
s.appendLeft(insertAt, `\nexport const meta = () => ${printMetaArray(entries, "")};\n`);
|
|
999
|
+
}
|
|
1000
|
+
await fs.writeFile(route.filePath, s.toString());
|
|
1001
|
+
this.registry = null;
|
|
1002
|
+
return {
|
|
1003
|
+
success: true,
|
|
1004
|
+
filePaths: [route.filePath]
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Render the read-only parts around an editable value: literal chunks as-is, other
|
|
1009
|
+
* translations resolved to the text they currently produce, anything dynamic as "…".
|
|
1010
|
+
*/
|
|
1011
|
+
async renderSegments(segments, language) {
|
|
1012
|
+
return (await Promise.all(segments.map(async (segment) => {
|
|
1013
|
+
if ("text" in segment) return segment.text;
|
|
1014
|
+
const { origin } = segment;
|
|
1015
|
+
if (origin.kind === "literal") return origin.value;
|
|
1016
|
+
if (origin.kind === "i18n") return readLocaleValue(await this.readLocale(language, origin.namespace), origin.key) ?? "";
|
|
1017
|
+
return "…";
|
|
1018
|
+
}))).join("");
|
|
1019
|
+
}
|
|
1020
|
+
/** Turn an analyzed meta element into what the settings panel needs to render. */
|
|
1021
|
+
async describeField(element, language, sources) {
|
|
1022
|
+
const { origin } = element;
|
|
1023
|
+
const [prefix, suffix] = await Promise.all([this.renderSegments(element.prefixSegments, language), this.renderSegments(element.suffixSegments, language)]);
|
|
1024
|
+
const base = {
|
|
1025
|
+
prefix,
|
|
1026
|
+
suffix
|
|
1027
|
+
};
|
|
1028
|
+
if (origin.kind === "unsupported" || origin.kind === "prop") {
|
|
1029
|
+
const reason = origin.kind === "prop" ? "This value is set by the page's SEO component" : origin.reason;
|
|
1030
|
+
return {
|
|
1031
|
+
...base,
|
|
1032
|
+
value: "",
|
|
1033
|
+
editable: false,
|
|
1034
|
+
reason,
|
|
1035
|
+
translated: false,
|
|
1036
|
+
shared: false
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
if (origin.kind === "absent") return {
|
|
1040
|
+
...base,
|
|
1041
|
+
value: "",
|
|
1042
|
+
editable: true,
|
|
1043
|
+
translated: false,
|
|
1044
|
+
shared: false
|
|
1045
|
+
};
|
|
1046
|
+
if (origin.kind === "literal") return {
|
|
1047
|
+
...base,
|
|
1048
|
+
value: origin.value,
|
|
1049
|
+
editable: true,
|
|
1050
|
+
translated: false,
|
|
1051
|
+
shared: false
|
|
1052
|
+
};
|
|
1053
|
+
const data = await this.readLocale(language, origin.namespace);
|
|
1054
|
+
return {
|
|
1055
|
+
...base,
|
|
1056
|
+
value: readLocaleValue(data, origin.key) ?? "",
|
|
1057
|
+
editable: true,
|
|
1058
|
+
translated: true,
|
|
1059
|
+
i18nKey: origin.key,
|
|
1060
|
+
shared: countKeyUsages(sources, origin.key) > 1
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
/** Languages the site ships translations for, from `app/locales/<lang>/`. */
|
|
1064
|
+
async listLanguages() {
|
|
1065
|
+
try {
|
|
1066
|
+
return (await fs.readdir(path.join(this.projectRoot, "app", "locales"), { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
1067
|
+
} catch {
|
|
1068
|
+
return [];
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
/** Requested language when it exists, else the site default, else the first available one. */
|
|
1072
|
+
async resolveLanguage(requested, languages) {
|
|
1073
|
+
if (requested && languages.includes(requested)) return requested;
|
|
1074
|
+
try {
|
|
1075
|
+
const raw = await fs.readFile(path.join(this.projectRoot, "app", "config", "site.json"), "utf-8");
|
|
1076
|
+
const config = JSON.parse(raw);
|
|
1077
|
+
if (config.defaultLanguage && languages.includes(config.defaultLanguage)) return config.defaultLanguage;
|
|
1078
|
+
} catch {}
|
|
1079
|
+
return languages[0] ?? "en";
|
|
1080
|
+
}
|
|
1081
|
+
localePath(language, namespace) {
|
|
1082
|
+
return path.join(this.projectRoot, "app", "locales", language, `${namespace}.json`);
|
|
1083
|
+
}
|
|
1084
|
+
async readLocale(language, namespace) {
|
|
1085
|
+
try {
|
|
1086
|
+
const raw = await fs.readFile(this.localePath(language, namespace), "utf-8");
|
|
1087
|
+
const parsed = JSON.parse(raw);
|
|
1088
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
1089
|
+
} catch {
|
|
1090
|
+
return {};
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Every source file of the app, keyed by absolute path. Serves two purposes at once:
|
|
1095
|
+
* telling whether a translation key is referenced more than once, and following a route
|
|
1096
|
+
* to the shared component it hands its metadata to.
|
|
1097
|
+
*/
|
|
1098
|
+
async readAppSources() {
|
|
1099
|
+
const files = /* @__PURE__ */ new Map();
|
|
1100
|
+
const visit = async (dir) => {
|
|
1101
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
1102
|
+
for (const entry of entries) {
|
|
1103
|
+
const full = path.join(dir, entry.name);
|
|
1104
|
+
if (entry.isDirectory()) {
|
|
1105
|
+
if (entry.name === "node_modules" || entry.name === "locales" || entry.name.startsWith(".")) continue;
|
|
1106
|
+
await visit(full);
|
|
1107
|
+
} else if (/\.(tsx?|jsx?)$/.test(entry.name)) try {
|
|
1108
|
+
files.set(full, await fs.readFile(full, "utf-8"));
|
|
1109
|
+
} catch {}
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
await visit(path.join(this.projectRoot, "app"));
|
|
1113
|
+
return files;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Resolve the import specifiers a route can use for a local component — "~/components/X"
|
|
1117
|
+
* (the template's alias for `app/`) and relative paths — against the files already read.
|
|
1118
|
+
*/
|
|
1119
|
+
createModuleLoader(files, fromFile) {
|
|
1120
|
+
return (specifier) => {
|
|
1121
|
+
let base;
|
|
1122
|
+
if (specifier.startsWith("~/")) base = path.join(this.projectRoot, "app", specifier.slice(2));
|
|
1123
|
+
else if (specifier.startsWith(".")) base = path.resolve(path.dirname(fromFile), specifier);
|
|
1124
|
+
else return null;
|
|
1125
|
+
for (const candidate of [
|
|
1126
|
+
base,
|
|
1127
|
+
`${base}.tsx`,
|
|
1128
|
+
`${base}.ts`,
|
|
1129
|
+
`${base}.jsx`,
|
|
1130
|
+
`${base}.js`,
|
|
1131
|
+
path.join(base, "index.tsx"),
|
|
1132
|
+
path.join(base, "index.ts")
|
|
1133
|
+
]) {
|
|
1134
|
+
const code = files.get(candidate);
|
|
1135
|
+
if (code !== void 0) return {
|
|
1136
|
+
code,
|
|
1137
|
+
filePath: candidate
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
return null;
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Pick a free key dedicated to this page's meta, derived from the shared key
|
|
1145
|
+
* ("atelier.title" + "title" -> "atelier.meta.title"). Never reuses a key that already
|
|
1146
|
+
* exists in the locales or is referenced by the code.
|
|
1147
|
+
*/
|
|
1148
|
+
async allocateMetaKey(sharedKey, fieldName, sources, namespace) {
|
|
1149
|
+
const base = sharedKey.split(".")[0] || "page";
|
|
1150
|
+
const languages = await this.listLanguages();
|
|
1151
|
+
const documents = await Promise.all(languages.map((lang) => this.readLocale(lang, namespace)));
|
|
1152
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
1153
|
+
const candidate = attempt === 0 ? `${base}.meta.${fieldName}` : `${base}.meta.${fieldName}${attempt + 1}`;
|
|
1154
|
+
if (!(countKeyUsages(sources, candidate) > 0 || documents.some((doc) => readLocaleValue(doc, candidate) !== null))) return candidate;
|
|
1155
|
+
}
|
|
1156
|
+
return `${base}.meta.${fieldName}.${Date.now()}`;
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Resolve a react-router route id ("routes/_layout._index", "root") to its source file
|
|
1160
|
+
* inside the workspace. Never escapes `<projectRoot>/app`.
|
|
1161
|
+
*/
|
|
1162
|
+
async resolveRouteFile(routeId) {
|
|
1163
|
+
const appDir = path.join(this.projectRoot, "app");
|
|
1164
|
+
for (const ext of [
|
|
1165
|
+
"tsx",
|
|
1166
|
+
"ts",
|
|
1167
|
+
"jsx",
|
|
1168
|
+
"js"
|
|
1169
|
+
]) {
|
|
1170
|
+
const filePath = path.join(appDir, `${routeId}.${ext}`);
|
|
1171
|
+
const resolved = path.resolve(filePath);
|
|
1172
|
+
if (resolved !== appDir && !resolved.startsWith(appDir + path.sep)) continue;
|
|
1173
|
+
try {
|
|
1174
|
+
await fs.access(resolved);
|
|
1175
|
+
return {
|
|
1176
|
+
filePath: resolved,
|
|
1177
|
+
relativeFile: path.relative(this.projectRoot, resolved)
|
|
1178
|
+
};
|
|
1179
|
+
} catch {}
|
|
1180
|
+
}
|
|
1181
|
+
return null;
|
|
1182
|
+
}
|
|
428
1183
|
/** Parse "<relativeFile>:<offset>" into an absolute path + numeric offset. */
|
|
429
1184
|
resolveArrayId(arrayId) {
|
|
430
1185
|
const sep = arrayId.lastIndexOf(":");
|
|
@@ -529,6 +1284,6 @@ var UpstartEditorAPI = class {
|
|
|
529
1284
|
}
|
|
530
1285
|
};
|
|
531
1286
|
//#endregion
|
|
532
|
-
export { UpstartEditorAPI, escapeStringLiteralBody, payloadArrayItemAdd, payloadArrayItemDelete, payloadArraySet, payloadEditClassName, payloadEditImage, payloadEditText, payloadEditTextDirect };
|
|
1287
|
+
export { UpstartEditorAPI, escapeStringLiteralBody, payloadArrayItemAdd, payloadArrayItemDelete, payloadArraySet, payloadEditClassName, payloadEditImage, payloadEditText, payloadEditTextDirect, payloadGetPageMeta, payloadGetSiteMeta, payloadSetPageMeta, payloadSetSiteMeta, readLocaleValue, writeLocaleValue };
|
|
533
1288
|
|
|
534
1289
|
//# sourceMappingURL=upstart-editor-api.js.map
|