@upstart.gg/vite-plugins 0.1.60 → 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-attrs.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-attrs.js +130 -10
- package/dist/vite-plugin-upstart-attrs.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/text-editor.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +139 -14
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.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/tests/vite-plugin-upstart-attrs.test.ts +224 -13
- package/src/upstart-editor-api.ts +941 -0
- package/src/vite-plugin-upstart-attrs.ts +253 -14
- package/src/vite-plugin-upstart-editor/runtime/index.ts +38 -0
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +141 -14
- package/src/vite-plugin-upstart-editor/runtime/types.ts +2 -0
|
@@ -4,6 +4,8 @@ import fs from "fs/promises";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import z from "zod";
|
|
6
6
|
import type { EditableEntry } from "./vite-plugin-upstart-attrs";
|
|
7
|
+
import { analyzeRouteMeta, type MetaElement, type MetaSegment, type ModuleLoader } from "./page-meta";
|
|
8
|
+
import { ensureRootSiteMeta } from "./site-meta";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Escape user-typed text so it can be safely written as the BODY of a JS string
|
|
@@ -79,6 +81,252 @@ function inferArrayQuote(code: string, elements: AstNode[]): string {
|
|
|
79
81
|
return '"';
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/** One `{ key: "value" }` entry of a route's `meta` array, keys in source order. */
|
|
85
|
+
type StaticMetaEntry = { key: string; value: string }[];
|
|
86
|
+
|
|
87
|
+
type MetaExportLookup =
|
|
88
|
+
| { kind: "none" }
|
|
89
|
+
| { kind: "dynamic"; reason: string }
|
|
90
|
+
| { kind: "static"; arrayStart: number; arrayEnd: number; entries: StaticMetaEntry[] };
|
|
91
|
+
|
|
92
|
+
/** Read the ArrayExpression a `meta` export resolves to, or explain why we can't. */
|
|
93
|
+
function readMetaArray(node: AstNode): { ok: true; array: AstNode } | { ok: false; reason: string } {
|
|
94
|
+
if (node.type === "ArrayExpression") return { ok: true, array: node };
|
|
95
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
|
|
96
|
+
const body = node.body as AstNode;
|
|
97
|
+
if (body.type === "ArrayExpression") return { ok: true, array: body };
|
|
98
|
+
if (body.type === "BlockStatement") {
|
|
99
|
+
const statements = (body.body as AstNode[]) ?? [];
|
|
100
|
+
const ret = statements.find((st) => st.type === "ReturnStatement");
|
|
101
|
+
const argument = ret?.argument as AstNode | null | undefined;
|
|
102
|
+
if (argument?.type === "ArrayExpression") return { ok: true, array: argument };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { ok: false, reason: "The page meta is computed by code" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Turn a static array of object literals into plain key/value entries, or null if any part is dynamic. */
|
|
109
|
+
function readStaticEntries(elements: (AstNode | null)[]): StaticMetaEntry[] | null {
|
|
110
|
+
const entries: StaticMetaEntry[] = [];
|
|
111
|
+
for (const element of elements) {
|
|
112
|
+
if (!element || element.type !== "ObjectExpression") return null;
|
|
113
|
+
const entry: StaticMetaEntry = [];
|
|
114
|
+
for (const prop of (element.properties as AstNode[]) ?? []) {
|
|
115
|
+
if (prop.type !== "Property" || prop.computed || prop.shorthand || prop.kind !== "init") return null;
|
|
116
|
+
const key = prop.key as AstNode;
|
|
117
|
+
const value = prop.value as AstNode;
|
|
118
|
+
const keyName =
|
|
119
|
+
key.type === "Identifier"
|
|
120
|
+
? (key.name as string)
|
|
121
|
+
: key.type === "Literal" && typeof key.value === "string"
|
|
122
|
+
? key.value
|
|
123
|
+
: null;
|
|
124
|
+
if (keyName === null) return null;
|
|
125
|
+
if (value.type !== "Literal" || typeof value.value !== "string") return null;
|
|
126
|
+
entry.push({ key: keyName, value: value.value });
|
|
127
|
+
}
|
|
128
|
+
entries.push(entry);
|
|
129
|
+
}
|
|
130
|
+
return entries;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Locate the route's `meta` export and read it if it is fully static. */
|
|
134
|
+
function findMetaExport(code: string, filePath: string): MetaExportLookup {
|
|
135
|
+
const ast = parseSync(filePath, code, { sourceType: "module" });
|
|
136
|
+
const body = (ast.program?.body as AstNode[] | undefined) ?? [];
|
|
137
|
+
|
|
138
|
+
for (const statement of body) {
|
|
139
|
+
if (statement.type !== "ExportNamedDeclaration") continue;
|
|
140
|
+
const declaration = statement.declaration as AstNode | null;
|
|
141
|
+
|
|
142
|
+
// export { meta } / export { x as meta } — we can't follow the reference.
|
|
143
|
+
if (!declaration) {
|
|
144
|
+
const specifiers = (statement.specifiers as AstNode[]) ?? [];
|
|
145
|
+
const exported = specifiers.some((spec) => {
|
|
146
|
+
const name = spec.exported as AstNode | undefined;
|
|
147
|
+
return name?.type === "Identifier" && name.name === "meta";
|
|
148
|
+
});
|
|
149
|
+
if (exported) return { kind: "dynamic", reason: "The page meta is exported indirectly" };
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let source: AstNode | null = null;
|
|
154
|
+
if (declaration.type === "VariableDeclaration") {
|
|
155
|
+
for (const decl of (declaration.declarations as AstNode[]) ?? []) {
|
|
156
|
+
const id = decl.id as AstNode;
|
|
157
|
+
if (id.type === "Identifier" && id.name === "meta") source = (decl.init as AstNode) ?? null;
|
|
158
|
+
}
|
|
159
|
+
} else if (declaration.type === "FunctionDeclaration") {
|
|
160
|
+
const id = declaration.id as AstNode | null;
|
|
161
|
+
if (id?.type === "Identifier" && id.name === "meta") source = declaration;
|
|
162
|
+
}
|
|
163
|
+
if (!source) continue;
|
|
164
|
+
|
|
165
|
+
const array = readMetaArray(source);
|
|
166
|
+
if (!array.ok) return { kind: "dynamic", reason: array.reason };
|
|
167
|
+
|
|
168
|
+
const entries = readStaticEntries((array.array.elements as (AstNode | null)[]) ?? []);
|
|
169
|
+
if (!entries) return { kind: "dynamic", reason: "The page meta contains dynamic values" };
|
|
170
|
+
|
|
171
|
+
return { kind: "static", arrayStart: array.array.start, arrayEnd: array.array.end, entries };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { kind: "none" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Offset right after the last top-level import, or the end of the file when there is none. */
|
|
178
|
+
function findMetaInsertOffset(code: string, filePath: string): number {
|
|
179
|
+
const ast = parseSync(filePath, code, { sourceType: "module" });
|
|
180
|
+
const body = (ast.program?.body as AstNode[] | undefined) ?? [];
|
|
181
|
+
let offset: number | null = null;
|
|
182
|
+
for (const statement of body) {
|
|
183
|
+
if (statement.type === "ImportDeclaration") offset = statement.end;
|
|
184
|
+
}
|
|
185
|
+
return offset ?? code.length;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Serialize entries back to source, one object literal per line. */
|
|
189
|
+
function printMetaArray(entries: StaticMetaEntry[], indent: string): string {
|
|
190
|
+
if (entries.length === 0) return "[]";
|
|
191
|
+
const lines = entries.map((entry) => {
|
|
192
|
+
const props = entry
|
|
193
|
+
.map(({ key, value }) => {
|
|
194
|
+
const printedKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
|
|
195
|
+
? key
|
|
196
|
+
: `"${escapeStringLiteralBody(key, '"')}"`;
|
|
197
|
+
return `${printedKey}: "${escapeStringLiteralBody(value, '"')}"`;
|
|
198
|
+
})
|
|
199
|
+
.join(", ");
|
|
200
|
+
return `${indent} { ${props} },`;
|
|
201
|
+
});
|
|
202
|
+
return `[\n${lines.join("\n")}\n${indent}]`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Read the value of `{ name: <name>, content: X }` (or `{ title: X }` when name is "title"). */
|
|
206
|
+
function findMetaValue(entries: StaticMetaEntry[], name: string): string | null {
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
if (name === "title") {
|
|
209
|
+
const title = entry.find((p) => p.key === "title");
|
|
210
|
+
if (title && entry.length === 1) return title.value;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (entry.some((p) => p.key === "name" && p.value === name)) {
|
|
214
|
+
return entry.find((p) => p.key === "content")?.value ?? null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Update/insert/remove the entry for `name`, leaving every other entry (og:*, twitter:*,
|
|
222
|
+
* canonical…) untouched and in place. An empty `value` removes the entry.
|
|
223
|
+
*/
|
|
224
|
+
function upsertMetaValue(entries: StaticMetaEntry[], name: string, value: string): StaticMetaEntry[] {
|
|
225
|
+
const matches = (entry: StaticMetaEntry) =>
|
|
226
|
+
name === "title"
|
|
227
|
+
? entry.length === 1 && entry[0].key === "title"
|
|
228
|
+
: entry.some((p) => p.key === "name" && p.value === name);
|
|
229
|
+
|
|
230
|
+
const index = entries.findIndex(matches);
|
|
231
|
+
const next = entries.filter((entry, i) => i === index || !matches(entry));
|
|
232
|
+
|
|
233
|
+
if (value === "") {
|
|
234
|
+
return next.filter((entry) => !matches(entry));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const entry: StaticMetaEntry =
|
|
238
|
+
name === "title"
|
|
239
|
+
? [{ key: "title", value }]
|
|
240
|
+
: [
|
|
241
|
+
{ key: "name", value: name },
|
|
242
|
+
{ key: "content", value },
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
if (index === -1) {
|
|
246
|
+
// Title first, everything else appended — matches how meta arrays are usually written.
|
|
247
|
+
return name === "title" ? [entry, ...next] : [...next, entry];
|
|
248
|
+
}
|
|
249
|
+
return next.map((existing, i) => (i === index ? entry : existing));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Number of times a translation key appears in the app sources. */
|
|
253
|
+
function countKeyUsages(sources: string, key: string): number {
|
|
254
|
+
let count = 0;
|
|
255
|
+
let index = sources.indexOf(key);
|
|
256
|
+
while (index !== -1) {
|
|
257
|
+
// Only count whole keys: "about.meta" must not match inside "about.meta.title".
|
|
258
|
+
const before = sources[index - 1];
|
|
259
|
+
const after = sources[index + key.length];
|
|
260
|
+
const isBoundary = (char: string | undefined) =>
|
|
261
|
+
char === undefined || char === '"' || char === "'" || char === "`" || char === ":";
|
|
262
|
+
if (isBoundary(before) && isBoundary(after)) count++;
|
|
263
|
+
index = sources.indexOf(key, index + key.length);
|
|
264
|
+
}
|
|
265
|
+
return count;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read a key from a locale document. i18next accepts both a flat key ("a.b" as a literal
|
|
270
|
+
* property) and a nested path, so both are tried — flat first, as the generated sites use it.
|
|
271
|
+
*/
|
|
272
|
+
export function readLocaleValue(data: Record<string, unknown>, key: string): string | null {
|
|
273
|
+
const flat = data[key];
|
|
274
|
+
if (typeof flat === "string") return flat;
|
|
275
|
+
let current: unknown = data;
|
|
276
|
+
for (const part of key.split(".")) {
|
|
277
|
+
if (!current || typeof current !== "object") return null;
|
|
278
|
+
current = (current as Record<string, unknown>)[part];
|
|
279
|
+
}
|
|
280
|
+
return typeof current === "string" ? current : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Write a key, keeping the shape it already has; new keys are created flat. */
|
|
284
|
+
export function writeLocaleValue(data: Record<string, unknown>, key: string, value: string): void {
|
|
285
|
+
if (typeof data[key] === "string") {
|
|
286
|
+
data[key] = value;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const parts = key.split(".");
|
|
290
|
+
let current: Record<string, unknown> = data;
|
|
291
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
292
|
+
const next = current[parts[i]];
|
|
293
|
+
if (!next || typeof next !== "object") {
|
|
294
|
+
data[key] = value;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
current = next as Record<string, unknown>;
|
|
298
|
+
}
|
|
299
|
+
const leaf = parts[parts.length - 1];
|
|
300
|
+
if (typeof current[leaf] === "string") {
|
|
301
|
+
current[leaf] = value;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
data[key] = value;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Render a meta tag to insert in a route that doesn't have one yet. Values that JSX can hold
|
|
309
|
+
* verbatim are written as plain text/attributes; anything else goes through an expression
|
|
310
|
+
* container so quotes and braces survive.
|
|
311
|
+
*/
|
|
312
|
+
function printMetaTag(fieldName: string, value: string): string {
|
|
313
|
+
if (fieldName === "title") {
|
|
314
|
+
const plain = !/[{}<>&\n]/.test(value);
|
|
315
|
+
return plain ? `<title>${value}</title>` : `<title>{"${escapeStringLiteralBody(value, '"')}"}</title>`;
|
|
316
|
+
}
|
|
317
|
+
const attribute = /["\n]/.test(value)
|
|
318
|
+
? `content={"${escapeStringLiteralBody(value, '"')}"}`
|
|
319
|
+
: `content="${value}"`;
|
|
320
|
+
return `<meta name="${fieldName}" ${attribute} />`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Start of the line `offset` sits on, so a removed tag doesn't leave a blank line behind. */
|
|
324
|
+
function lineRangeStart(code: string, offset: number): number {
|
|
325
|
+
const lineStart = code.lastIndexOf("\n", offset);
|
|
326
|
+
if (lineStart === -1) return offset;
|
|
327
|
+
return code.slice(lineStart + 1, offset).trim() === "" ? lineStart : offset;
|
|
328
|
+
}
|
|
329
|
+
|
|
82
330
|
export const payloadEditText = z.object({
|
|
83
331
|
action: z.literal("editText"),
|
|
84
332
|
language: z
|
|
@@ -146,6 +394,117 @@ export const payloadArraySet = z.object({
|
|
|
146
394
|
|
|
147
395
|
export type PayloadArraySet = z.infer<typeof payloadArraySet>;
|
|
148
396
|
|
|
397
|
+
// React-router route id, as reported by the client runtime (e.g. "routes/_layout._index",
|
|
398
|
+
// "root"). Resolved to `<projectRoot>/app/<routeId>.<ext>` — see `resolveRouteFile`.
|
|
399
|
+
const routeIdSchema = z
|
|
400
|
+
.string()
|
|
401
|
+
.min(1)
|
|
402
|
+
// Flat-route ids carry the file-name conventions: optional segments "($lang)", dynamic
|
|
403
|
+
// "$slug", escaped characters "sitemap[.]xml", layout prefixes "_layout.".
|
|
404
|
+
.regex(/^[a-zA-Z0-9._$/()[\]+~-]+$/)
|
|
405
|
+
.refine((id) => !id.split("/").includes("..") && !id.startsWith("/"), "Invalid route id");
|
|
406
|
+
|
|
407
|
+
export const payloadGetPageMeta = z.object({
|
|
408
|
+
action: z.literal("getPageMeta"),
|
|
409
|
+
routeId: routeIdSchema,
|
|
410
|
+
/** Locale to read the translated values in. Defaults to the site's default language. */
|
|
411
|
+
language: z
|
|
412
|
+
.string()
|
|
413
|
+
.regex(/^[a-zA-Z-]{2,10}$/)
|
|
414
|
+
.optional(),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
export type PayloadGetPageMeta = z.infer<typeof payloadGetPageMeta>;
|
|
418
|
+
|
|
419
|
+
export const payloadSetPageMeta = z.object({
|
|
420
|
+
action: z.literal("setPageMeta"),
|
|
421
|
+
routeId: routeIdSchema,
|
|
422
|
+
language: z
|
|
423
|
+
.string()
|
|
424
|
+
.regex(/^[a-zA-Z-]{2,10}$/)
|
|
425
|
+
.optional(),
|
|
426
|
+
// Empty string removes the corresponding meta tag (keywords, robots) or clears the text.
|
|
427
|
+
title: z.string().default(""),
|
|
428
|
+
description: z.string().default(""),
|
|
429
|
+
keywords: z.string().default(""),
|
|
430
|
+
robotsIndexing: z.boolean().default(true),
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
export type PayloadSetPageMeta = z.infer<typeof payloadSetPageMeta>;
|
|
434
|
+
|
|
435
|
+
export const payloadGetSiteMeta = z.object({
|
|
436
|
+
action: z.literal("getSiteMeta"),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
export type PayloadGetSiteMeta = z.infer<typeof payloadGetSiteMeta>;
|
|
440
|
+
|
|
441
|
+
// Site-root-relative path of an image already copied into the workspace, e.g. "/images/x.webp".
|
|
442
|
+
const publicImagePath = z
|
|
443
|
+
.string()
|
|
444
|
+
.regex(/^\/[A-Za-z0-9._\-/]+$/)
|
|
445
|
+
.refine((value) => !value.includes(".."), "Invalid image path");
|
|
446
|
+
|
|
447
|
+
export const payloadSetSiteMeta = z.object({
|
|
448
|
+
action: z.literal("setSiteMeta"),
|
|
449
|
+
// `null` clears the setting, `undefined` leaves it untouched.
|
|
450
|
+
favicon: publicImagePath.nullable().optional(),
|
|
451
|
+
socialImage: publicImagePath.nullable().optional(),
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
export type PayloadSetSiteMeta = z.infer<typeof payloadSetSiteMeta>;
|
|
455
|
+
|
|
456
|
+
export type GetSiteMetaResult =
|
|
457
|
+
| {
|
|
458
|
+
success: true;
|
|
459
|
+
favicon: string | null;
|
|
460
|
+
socialImage: string | null;
|
|
461
|
+
/** False when root.tsx was customized and can no longer be upgraded automatically. */
|
|
462
|
+
editable: boolean;
|
|
463
|
+
reason?: string;
|
|
464
|
+
}
|
|
465
|
+
| { success: false; error: string };
|
|
466
|
+
|
|
467
|
+
export type SetSiteMetaResult = { success: true; filePaths: string[] } | { success: false; error: string };
|
|
468
|
+
|
|
469
|
+
export interface PageMetaField {
|
|
470
|
+
value: string;
|
|
471
|
+
editable: boolean;
|
|
472
|
+
/** Why the field is read-only (dynamic value, computed title…). */
|
|
473
|
+
reason?: string;
|
|
474
|
+
/** True when the text lives in the locale files rather than in the route source. */
|
|
475
|
+
translated: boolean;
|
|
476
|
+
i18nKey?: string;
|
|
477
|
+
/**
|
|
478
|
+
* True when the translation key is also used elsewhere (typically as a heading on the
|
|
479
|
+
* page). Saving then moves this page's meta onto its own key instead of rewriting the
|
|
480
|
+
* shared one.
|
|
481
|
+
*/
|
|
482
|
+
shared: boolean;
|
|
483
|
+
/** Static text rendered around the value, e.g. `{`${title} | Acme`}`. */
|
|
484
|
+
prefix: string;
|
|
485
|
+
suffix: string;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export type GetPageMetaResult =
|
|
489
|
+
| {
|
|
490
|
+
success: true;
|
|
491
|
+
filePath: string;
|
|
492
|
+
relativeFile: string;
|
|
493
|
+
/** "jsx" for React-rendered meta tags, "meta-export" for a `export const meta` route. */
|
|
494
|
+
mode: "jsx" | "meta-export";
|
|
495
|
+
languages: string[];
|
|
496
|
+
language: string;
|
|
497
|
+
title: PageMetaField;
|
|
498
|
+
description: PageMetaField;
|
|
499
|
+
keywords: PageMetaField;
|
|
500
|
+
robotsIndexing: boolean;
|
|
501
|
+
robotsEditable: boolean;
|
|
502
|
+
robotsReason?: string;
|
|
503
|
+
}
|
|
504
|
+
| { success: false; error: string };
|
|
505
|
+
|
|
506
|
+
export type SetPageMetaResult = { success: true; filePaths: string[] } | { success: false; error: string };
|
|
507
|
+
|
|
149
508
|
export interface EditableRegistry {
|
|
150
509
|
version: number;
|
|
151
510
|
generatedAt: string;
|
|
@@ -492,6 +851,588 @@ export class UpstartEditorAPI {
|
|
|
492
851
|
}
|
|
493
852
|
}
|
|
494
853
|
|
|
854
|
+
/**
|
|
855
|
+
* Read the page metadata shown in the browser tab and in search results.
|
|
856
|
+
*
|
|
857
|
+
* Two shapes are supported, in this order: React-rendered tags (`<title>{title}</title>`
|
|
858
|
+
* fed by the loader, the shape the AI assistant generates — the text then lives in the
|
|
859
|
+
* locale files), and a static `export const meta` array.
|
|
860
|
+
*/
|
|
861
|
+
async getPageMeta(params: PayloadGetPageMeta): Promise<GetPageMetaResult> {
|
|
862
|
+
const parsed = payloadGetPageMeta.safeParse(params);
|
|
863
|
+
if (!parsed.success) {
|
|
864
|
+
return { success: false, error: `Invalid payload: ${parsed.error.message}` };
|
|
865
|
+
}
|
|
866
|
+
const route = await this.resolveRouteFile(parsed.data.routeId);
|
|
867
|
+
if (!route) {
|
|
868
|
+
return { success: false, error: `Route file not found for "${parsed.data.routeId}"` };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
let code: string;
|
|
872
|
+
try {
|
|
873
|
+
code = await fs.readFile(route.filePath, "utf-8");
|
|
874
|
+
} catch {
|
|
875
|
+
return { success: false, error: `Failed to read route file: ${route.relativeFile}` };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const languages = await this.listLanguages();
|
|
879
|
+
const language = await this.resolveLanguage(parsed.data.language, languages);
|
|
880
|
+
const files = await this.readAppSources();
|
|
881
|
+
const analysis = analyzeRouteMeta(
|
|
882
|
+
code,
|
|
883
|
+
route.relativeFile,
|
|
884
|
+
this.createModuleLoader(files, route.filePath),
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
if (analysis.hasJsxMeta) {
|
|
888
|
+
const sources = [...files.values()].join("\n");
|
|
889
|
+
const [title, description, keywords] = await Promise.all([
|
|
890
|
+
this.describeField(analysis.title, language, sources),
|
|
891
|
+
this.describeField(analysis.description, language, sources),
|
|
892
|
+
this.describeField(analysis.keywords, language, sources),
|
|
893
|
+
]);
|
|
894
|
+
const robots = analysis.robots.origin;
|
|
895
|
+
const robotsEditable = robots.kind === "absent" || robots.kind === "literal";
|
|
896
|
+
return {
|
|
897
|
+
success: true,
|
|
898
|
+
...route,
|
|
899
|
+
mode: "jsx",
|
|
900
|
+
languages,
|
|
901
|
+
language,
|
|
902
|
+
title,
|
|
903
|
+
description,
|
|
904
|
+
keywords,
|
|
905
|
+
robotsIndexing: robots.kind === "literal" ? !/noindex/i.test(robots.value) : true,
|
|
906
|
+
robotsEditable,
|
|
907
|
+
...(robotsEditable ? {} : { robotsReason: "This tag is computed by code" }),
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const plain = (value: string, editable: boolean, reason?: string): PageMetaField => ({
|
|
912
|
+
value,
|
|
913
|
+
editable,
|
|
914
|
+
...(reason ? { reason } : {}),
|
|
915
|
+
translated: false,
|
|
916
|
+
shared: false,
|
|
917
|
+
prefix: "",
|
|
918
|
+
suffix: "",
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
const lookup = findMetaExport(code, route.relativeFile);
|
|
922
|
+
if (lookup.kind === "dynamic") {
|
|
923
|
+
return {
|
|
924
|
+
success: true,
|
|
925
|
+
...route,
|
|
926
|
+
mode: "meta-export",
|
|
927
|
+
languages,
|
|
928
|
+
language,
|
|
929
|
+
title: plain("", false, lookup.reason),
|
|
930
|
+
description: plain("", false, lookup.reason),
|
|
931
|
+
keywords: plain("", false, lookup.reason),
|
|
932
|
+
robotsIndexing: true,
|
|
933
|
+
robotsEditable: false,
|
|
934
|
+
robotsReason: lookup.reason,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const entries = lookup.kind === "static" ? lookup.entries : [];
|
|
939
|
+
const robotsValue = findMetaValue(entries, "robots");
|
|
940
|
+
return {
|
|
941
|
+
success: true,
|
|
942
|
+
...route,
|
|
943
|
+
mode: "meta-export",
|
|
944
|
+
languages,
|
|
945
|
+
language,
|
|
946
|
+
title: plain(findMetaValue(entries, "title") ?? "", true),
|
|
947
|
+
description: plain(findMetaValue(entries, "description") ?? "", true),
|
|
948
|
+
keywords: plain(findMetaValue(entries, "keywords") ?? "", true),
|
|
949
|
+
robotsIndexing: robotsValue === null ? true : !/noindex/i.test(robotsValue),
|
|
950
|
+
robotsEditable: true,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Write the page metadata back. Values backed by a translation key are written to the
|
|
956
|
+
* locale file of `language`; everything else is written into the route source. Returns
|
|
957
|
+
* every file that changed so the caller can commit them together.
|
|
958
|
+
*/
|
|
959
|
+
async setPageMeta(params: PayloadSetPageMeta): Promise<SetPageMetaResult> {
|
|
960
|
+
const parsed = payloadSetPageMeta.safeParse(params);
|
|
961
|
+
if (!parsed.success) {
|
|
962
|
+
return { success: false, error: `Invalid payload: ${parsed.error.message}` };
|
|
963
|
+
}
|
|
964
|
+
const { routeId, title, description, keywords, robotsIndexing } = parsed.data;
|
|
965
|
+
const route = await this.resolveRouteFile(routeId);
|
|
966
|
+
if (!route) {
|
|
967
|
+
return { success: false, error: `Route file not found for "${routeId}"` };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
try {
|
|
971
|
+
const code = await fs.readFile(route.filePath, "utf-8");
|
|
972
|
+
const languages = await this.listLanguages();
|
|
973
|
+
const language = await this.resolveLanguage(parsed.data.language, languages);
|
|
974
|
+
const files = await this.readAppSources();
|
|
975
|
+
const analysis = analyzeRouteMeta(
|
|
976
|
+
code,
|
|
977
|
+
route.relativeFile,
|
|
978
|
+
this.createModuleLoader(files, route.filePath),
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
if (!analysis.hasJsxMeta) {
|
|
982
|
+
return this.setMetaExport(route, code, { title, description, keywords, robotsIndexing });
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const sources = [...files.values()].join("\n");
|
|
986
|
+
const s = new MagicString(code);
|
|
987
|
+
// Locale documents are loaded once, mutated by every field, then written back.
|
|
988
|
+
const locales = new Map<string, Record<string, unknown>>();
|
|
989
|
+
const changedLocales = new Set<string>();
|
|
990
|
+
let sourceChanged = false;
|
|
991
|
+
|
|
992
|
+
const loadLocale = async (lang: string, namespace: string) => {
|
|
993
|
+
const id = `${lang}/${namespace}`;
|
|
994
|
+
const cached = locales.get(id);
|
|
995
|
+
if (cached) return cached;
|
|
996
|
+
const data = await this.readLocale(lang, namespace);
|
|
997
|
+
locales.set(id, data);
|
|
998
|
+
return data;
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
const applyField = async (element: MetaElement, next: string, fieldName: string) => {
|
|
1002
|
+
const { origin } = element;
|
|
1003
|
+
// "prop" is resolved while analyzing; it should never reach here, and writing it
|
|
1004
|
+
// would have nowhere to go.
|
|
1005
|
+
if (origin.kind === "unsupported" || origin.kind === "prop") return;
|
|
1006
|
+
|
|
1007
|
+
if (origin.kind === "absent") {
|
|
1008
|
+
if (next === "" || analysis.insertOffset === null) return;
|
|
1009
|
+
s.appendLeft(analysis.insertOffset, `\n${analysis.insertIndent}${printMetaTag(fieldName, next)}`);
|
|
1010
|
+
sourceChanged = true;
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (origin.kind === "literal") {
|
|
1015
|
+
if (origin.value === next) return;
|
|
1016
|
+
s.overwrite(origin.start, origin.end, escapeStringLiteralBody(next, origin.quote));
|
|
1017
|
+
sourceChanged = true;
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Translation-backed value: write to the locale file rather than to the route.
|
|
1022
|
+
const data = await loadLocale(language, origin.namespace);
|
|
1023
|
+
const current = readLocaleValue(data, origin.key) ?? "";
|
|
1024
|
+
if (current === next) return;
|
|
1025
|
+
|
|
1026
|
+
if (countKeyUsages(sources, origin.key) > 1) {
|
|
1027
|
+
// The key is also used elsewhere (usually a heading rendered on the page), so
|
|
1028
|
+
// give this page its own key instead of silently rewriting the shared text.
|
|
1029
|
+
const newKey = await this.allocateMetaKey(origin.key, fieldName, sources, origin.namespace);
|
|
1030
|
+
for (const lang of languages) {
|
|
1031
|
+
const langData = await loadLocale(lang, origin.namespace);
|
|
1032
|
+
const previous = readLocaleValue(langData, origin.key);
|
|
1033
|
+
// A language that never translated the shared key stays untranslated: writing an
|
|
1034
|
+
// empty string there would render an empty title instead of falling back.
|
|
1035
|
+
if (previous === null) continue;
|
|
1036
|
+
writeLocaleValue(langData, newKey, previous);
|
|
1037
|
+
changedLocales.add(`${lang}/${origin.namespace}`);
|
|
1038
|
+
}
|
|
1039
|
+
const raw = code.slice(origin.keyStart, origin.keyEnd);
|
|
1040
|
+
const prefix = raw.includes(":") ? `${raw.slice(0, raw.indexOf(":") + 1)}` : "";
|
|
1041
|
+
s.overwrite(origin.keyStart, origin.keyEnd, `${prefix}${newKey}`);
|
|
1042
|
+
sourceChanged = true;
|
|
1043
|
+
writeLocaleValue(await loadLocale(language, origin.namespace), newKey, next);
|
|
1044
|
+
} else {
|
|
1045
|
+
writeLocaleValue(data, origin.key, next);
|
|
1046
|
+
}
|
|
1047
|
+
changedLocales.add(`${language}/${origin.namespace}`);
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
await applyField(analysis.title, title, "title");
|
|
1051
|
+
await applyField(analysis.description, description, "description");
|
|
1052
|
+
await applyField(analysis.keywords, keywords, "keywords");
|
|
1053
|
+
|
|
1054
|
+
// Robots is a plain on/off tag: absent means "indexable".
|
|
1055
|
+
const robots = analysis.robots;
|
|
1056
|
+
if (robotsIndexing) {
|
|
1057
|
+
if (robots.origin.kind === "literal" && robots.elementStart !== undefined) {
|
|
1058
|
+
s.remove(lineRangeStart(code, robots.elementStart), robots.elementEnd as number);
|
|
1059
|
+
sourceChanged = true;
|
|
1060
|
+
}
|
|
1061
|
+
} else if (robots.origin.kind === "literal") {
|
|
1062
|
+
if (!/noindex/i.test(robots.origin.value)) {
|
|
1063
|
+
s.overwrite(robots.origin.start, robots.origin.end, "noindex, nofollow");
|
|
1064
|
+
sourceChanged = true;
|
|
1065
|
+
}
|
|
1066
|
+
} else if (robots.origin.kind === "absent" && analysis.insertOffset !== null) {
|
|
1067
|
+
s.appendLeft(
|
|
1068
|
+
analysis.insertOffset,
|
|
1069
|
+
`\n${analysis.insertIndent}${printMetaTag("robots", "noindex, nofollow")}`,
|
|
1070
|
+
);
|
|
1071
|
+
sourceChanged = true;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const filePaths: string[] = [];
|
|
1075
|
+
if (sourceChanged) {
|
|
1076
|
+
await fs.writeFile(route.filePath, s.toString());
|
|
1077
|
+
filePaths.push(route.filePath);
|
|
1078
|
+
// Byte offsets in the registry are stale after a source rewrite.
|
|
1079
|
+
this.registry = null;
|
|
1080
|
+
}
|
|
1081
|
+
for (const id of changedLocales) {
|
|
1082
|
+
const [lang, namespace] = id.split("/");
|
|
1083
|
+
const data = locales.get(id);
|
|
1084
|
+
if (!data) continue;
|
|
1085
|
+
const localePath = this.localePath(lang, namespace);
|
|
1086
|
+
await fs.writeFile(localePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
1087
|
+
filePaths.push(localePath);
|
|
1088
|
+
}
|
|
1089
|
+
return { success: true, filePaths };
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
return { success: false, error: String(err) };
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Read the site-wide settings rendered by `app/root.tsx`: the browser-tab icon and the
|
|
1097
|
+
* image shown when a page is shared on social networks.
|
|
1098
|
+
*/
|
|
1099
|
+
async getSiteMeta(params: PayloadGetSiteMeta): Promise<GetSiteMetaResult> {
|
|
1100
|
+
const parsed = payloadGetSiteMeta.safeParse(params);
|
|
1101
|
+
if (!parsed.success) {
|
|
1102
|
+
return { success: false, error: `Invalid payload: ${parsed.error.message}` };
|
|
1103
|
+
}
|
|
1104
|
+
const config = await this.readSiteConfig();
|
|
1105
|
+
if (!config) return { success: false, error: "This site has no app/config/site.json" };
|
|
1106
|
+
|
|
1107
|
+
// Report up front whether saving would be able to upgrade root.tsx, so the panel can
|
|
1108
|
+
// stay read-only instead of failing at save time.
|
|
1109
|
+
const root = await this.readRootFile();
|
|
1110
|
+
const upgrade = root ? ensureRootSiteMeta(root.code, root.relativeFile) : null;
|
|
1111
|
+
|
|
1112
|
+
return {
|
|
1113
|
+
success: true,
|
|
1114
|
+
favicon: typeof config.favicon === "string" ? config.favicon : null,
|
|
1115
|
+
socialImage: typeof config.socialImage === "string" ? config.socialImage : null,
|
|
1116
|
+
editable: !!upgrade?.ok,
|
|
1117
|
+
...(upgrade && !upgrade.ok ? { reason: upgrade.reason } : {}),
|
|
1118
|
+
...(root ? {} : { reason: "This site has no app/root.tsx" }),
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Write the site-wide settings to `app/config/site.json`, upgrading `app/root.tsx` to
|
|
1124
|
+
* render them if it does not already. The image files themselves are copied into the
|
|
1125
|
+
* workspace by the caller, which has bucket access.
|
|
1126
|
+
*/
|
|
1127
|
+
async setSiteMeta(params: PayloadSetSiteMeta): Promise<SetSiteMetaResult> {
|
|
1128
|
+
const parsed = payloadSetSiteMeta.safeParse(params);
|
|
1129
|
+
if (!parsed.success) {
|
|
1130
|
+
return { success: false, error: `Invalid payload: ${parsed.error.message}` };
|
|
1131
|
+
}
|
|
1132
|
+
const { favicon, socialImage } = parsed.data;
|
|
1133
|
+
if (favicon === undefined && socialImage === undefined) {
|
|
1134
|
+
return { success: true, filePaths: [] };
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
const config = await this.readSiteConfig();
|
|
1139
|
+
if (!config) return { success: false, error: "This site has no app/config/site.json" };
|
|
1140
|
+
|
|
1141
|
+
const filePaths: string[] = [];
|
|
1142
|
+
const root = await this.readRootFile();
|
|
1143
|
+
if (!root) return { success: false, error: "This site has no app/root.tsx" };
|
|
1144
|
+
const upgrade = ensureRootSiteMeta(root.code, root.relativeFile);
|
|
1145
|
+
if (!upgrade.ok) {
|
|
1146
|
+
return { success: false, error: `${upgrade.reason} — ask Upsie to update it` };
|
|
1147
|
+
}
|
|
1148
|
+
if (upgrade.changed) {
|
|
1149
|
+
await fs.writeFile(root.filePath, upgrade.code);
|
|
1150
|
+
filePaths.push(root.filePath);
|
|
1151
|
+
// Byte offsets recorded for inline editing no longer match the rewritten file.
|
|
1152
|
+
this.registry = null;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const next = { ...config };
|
|
1156
|
+
const apply = (key: "favicon" | "socialImage", value: string | null | undefined) => {
|
|
1157
|
+
if (value === undefined) return;
|
|
1158
|
+
if (value === null) delete next[key];
|
|
1159
|
+
else next[key] = value;
|
|
1160
|
+
};
|
|
1161
|
+
apply("favicon", favicon);
|
|
1162
|
+
apply("socialImage", socialImage);
|
|
1163
|
+
|
|
1164
|
+
const configPath = path.join(this.projectRoot, "app", "config", "site.json");
|
|
1165
|
+
await fs.writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
1166
|
+
filePaths.push(configPath);
|
|
1167
|
+
return { success: true, filePaths };
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
return { success: false, error: String(err) };
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
private async readSiteConfig(): Promise<Record<string, unknown> | null> {
|
|
1174
|
+
try {
|
|
1175
|
+
const raw = await fs.readFile(path.join(this.projectRoot, "app", "config", "site.json"), "utf-8");
|
|
1176
|
+
const parsed = JSON.parse(raw);
|
|
1177
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
|
1178
|
+
} catch {
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
private async readRootFile(): Promise<{ filePath: string; relativeFile: string; code: string } | null> {
|
|
1184
|
+
for (const ext of ["tsx", "jsx"]) {
|
|
1185
|
+
const filePath = path.join(this.projectRoot, "app", `root.${ext}`);
|
|
1186
|
+
try {
|
|
1187
|
+
return {
|
|
1188
|
+
filePath,
|
|
1189
|
+
relativeFile: path.relative(this.projectRoot, filePath),
|
|
1190
|
+
code: await fs.readFile(filePath, "utf-8"),
|
|
1191
|
+
};
|
|
1192
|
+
} catch {
|
|
1193
|
+
// try next extension
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** Rewrite a static `export const meta` array (routes that don't render meta as JSX). */
|
|
1200
|
+
private async setMetaExport(
|
|
1201
|
+
route: { filePath: string; relativeFile: string },
|
|
1202
|
+
code: string,
|
|
1203
|
+
values: { title: string; description: string; keywords: string; robotsIndexing: boolean },
|
|
1204
|
+
): Promise<SetPageMetaResult> {
|
|
1205
|
+
const lookup = findMetaExport(code, route.relativeFile);
|
|
1206
|
+
if (lookup.kind === "dynamic") {
|
|
1207
|
+
return { success: false, error: `${lookup.reason} and cannot be edited here` };
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
let entries = lookup.kind === "static" ? lookup.entries : [];
|
|
1211
|
+
entries = upsertMetaValue(entries, "title", values.title);
|
|
1212
|
+
entries = upsertMetaValue(entries, "description", values.description);
|
|
1213
|
+
entries = upsertMetaValue(entries, "keywords", values.keywords);
|
|
1214
|
+
entries = upsertMetaValue(entries, "robots", values.robotsIndexing ? "" : "noindex, nofollow");
|
|
1215
|
+
|
|
1216
|
+
const s = new MagicString(code);
|
|
1217
|
+
if (lookup.kind === "static") {
|
|
1218
|
+
// Indentation of the line the array starts on, so the rewritten literal lines up.
|
|
1219
|
+
const lineStart = code.lastIndexOf("\n", lookup.arrayStart) + 1;
|
|
1220
|
+
const indent = code.slice(lineStart, lookup.arrayStart).match(/^[ \t]*/)?.[0] ?? "";
|
|
1221
|
+
s.overwrite(lookup.arrayStart, lookup.arrayEnd, printMetaArray(entries, indent));
|
|
1222
|
+
} else {
|
|
1223
|
+
// No `meta` export yet: insert one after the imports (or at the end of the file).
|
|
1224
|
+
const insertAt = findMetaInsertOffset(code, route.relativeFile);
|
|
1225
|
+
s.appendLeft(insertAt, `\nexport const meta = () => ${printMetaArray(entries, "")};\n`);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
await fs.writeFile(route.filePath, s.toString());
|
|
1229
|
+
this.registry = null;
|
|
1230
|
+
return { success: true, filePaths: [route.filePath] };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Render the read-only parts around an editable value: literal chunks as-is, other
|
|
1235
|
+
* translations resolved to the text they currently produce, anything dynamic as "…".
|
|
1236
|
+
*/
|
|
1237
|
+
private async renderSegments(segments: MetaSegment[], language: string): Promise<string> {
|
|
1238
|
+
const parts = await Promise.all(
|
|
1239
|
+
segments.map(async (segment) => {
|
|
1240
|
+
if ("text" in segment) return segment.text;
|
|
1241
|
+
const { origin } = segment;
|
|
1242
|
+
if (origin.kind === "literal") return origin.value;
|
|
1243
|
+
if (origin.kind === "i18n") {
|
|
1244
|
+
const data = await this.readLocale(language, origin.namespace);
|
|
1245
|
+
return readLocaleValue(data, origin.key) ?? "";
|
|
1246
|
+
}
|
|
1247
|
+
return "…";
|
|
1248
|
+
}),
|
|
1249
|
+
);
|
|
1250
|
+
return parts.join("");
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Turn an analyzed meta element into what the settings panel needs to render. */
|
|
1254
|
+
private async describeField(
|
|
1255
|
+
element: MetaElement,
|
|
1256
|
+
language: string,
|
|
1257
|
+
sources: string,
|
|
1258
|
+
): Promise<PageMetaField> {
|
|
1259
|
+
const { origin } = element;
|
|
1260
|
+
const [prefix, suffix] = await Promise.all([
|
|
1261
|
+
this.renderSegments(element.prefixSegments, language),
|
|
1262
|
+
this.renderSegments(element.suffixSegments, language),
|
|
1263
|
+
]);
|
|
1264
|
+
const base = { prefix, suffix };
|
|
1265
|
+
if (origin.kind === "unsupported" || origin.kind === "prop") {
|
|
1266
|
+
const reason = origin.kind === "prop" ? "This value is set by the page's SEO component" : origin.reason;
|
|
1267
|
+
return { ...base, value: "", editable: false, reason, translated: false, shared: false };
|
|
1268
|
+
}
|
|
1269
|
+
if (origin.kind === "absent") {
|
|
1270
|
+
return { ...base, value: "", editable: true, translated: false, shared: false };
|
|
1271
|
+
}
|
|
1272
|
+
if (origin.kind === "literal") {
|
|
1273
|
+
return { ...base, value: origin.value, editable: true, translated: false, shared: false };
|
|
1274
|
+
}
|
|
1275
|
+
const data = await this.readLocale(language, origin.namespace);
|
|
1276
|
+
return {
|
|
1277
|
+
...base,
|
|
1278
|
+
value: readLocaleValue(data, origin.key) ?? "",
|
|
1279
|
+
editable: true,
|
|
1280
|
+
translated: true,
|
|
1281
|
+
i18nKey: origin.key,
|
|
1282
|
+
shared: countKeyUsages(sources, origin.key) > 1,
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/** Languages the site ships translations for, from `app/locales/<lang>/`. */
|
|
1287
|
+
private async listLanguages(): Promise<string[]> {
|
|
1288
|
+
try {
|
|
1289
|
+
const entries = await fs.readdir(path.join(this.projectRoot, "app", "locales"), {
|
|
1290
|
+
withFileTypes: true,
|
|
1291
|
+
});
|
|
1292
|
+
return entries
|
|
1293
|
+
.filter((entry) => entry.isDirectory())
|
|
1294
|
+
.map((entry) => entry.name)
|
|
1295
|
+
.sort();
|
|
1296
|
+
} catch {
|
|
1297
|
+
return [];
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** Requested language when it exists, else the site default, else the first available one. */
|
|
1302
|
+
private async resolveLanguage(requested: string | undefined, languages: string[]): Promise<string> {
|
|
1303
|
+
if (requested && languages.includes(requested)) return requested;
|
|
1304
|
+
try {
|
|
1305
|
+
const raw = await fs.readFile(path.join(this.projectRoot, "app", "config", "site.json"), "utf-8");
|
|
1306
|
+
const config = JSON.parse(raw) as { defaultLanguage?: string | null };
|
|
1307
|
+
if (config.defaultLanguage && languages.includes(config.defaultLanguage)) {
|
|
1308
|
+
return config.defaultLanguage;
|
|
1309
|
+
}
|
|
1310
|
+
} catch {
|
|
1311
|
+
// no site config — fall through
|
|
1312
|
+
}
|
|
1313
|
+
return languages[0] ?? "en";
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
private localePath(language: string, namespace: string): string {
|
|
1317
|
+
return path.join(this.projectRoot, "app", "locales", language, `${namespace}.json`);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
private async readLocale(language: string, namespace: string): Promise<Record<string, unknown>> {
|
|
1321
|
+
try {
|
|
1322
|
+
const raw = await fs.readFile(this.localePath(language, namespace), "utf-8");
|
|
1323
|
+
const parsed = JSON.parse(raw);
|
|
1324
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
1325
|
+
} catch {
|
|
1326
|
+
return {};
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Every source file of the app, keyed by absolute path. Serves two purposes at once:
|
|
1332
|
+
* telling whether a translation key is referenced more than once, and following a route
|
|
1333
|
+
* to the shared component it hands its metadata to.
|
|
1334
|
+
*/
|
|
1335
|
+
private async readAppSources(): Promise<Map<string, string>> {
|
|
1336
|
+
const files = new Map<string, string>();
|
|
1337
|
+
const visit = async (dir: string): Promise<void> => {
|
|
1338
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
1339
|
+
for (const entry of entries) {
|
|
1340
|
+
const full = path.join(dir, entry.name);
|
|
1341
|
+
if (entry.isDirectory()) {
|
|
1342
|
+
if (entry.name === "node_modules" || entry.name === "locales" || entry.name.startsWith(".")) {
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
await visit(full);
|
|
1346
|
+
} else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
|
1347
|
+
try {
|
|
1348
|
+
files.set(full, await fs.readFile(full, "utf-8"));
|
|
1349
|
+
} catch {
|
|
1350
|
+
// unreadable file — ignore
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
await visit(path.join(this.projectRoot, "app"));
|
|
1356
|
+
return files;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Resolve the import specifiers a route can use for a local component — "~/components/X"
|
|
1361
|
+
* (the template's alias for `app/`) and relative paths — against the files already read.
|
|
1362
|
+
*/
|
|
1363
|
+
private createModuleLoader(files: Map<string, string>, fromFile: string): ModuleLoader {
|
|
1364
|
+
return (specifier) => {
|
|
1365
|
+
let base: string;
|
|
1366
|
+
if (specifier.startsWith("~/")) {
|
|
1367
|
+
base = path.join(this.projectRoot, "app", specifier.slice(2));
|
|
1368
|
+
} else if (specifier.startsWith(".")) {
|
|
1369
|
+
base = path.resolve(path.dirname(fromFile), specifier);
|
|
1370
|
+
} else {
|
|
1371
|
+
return null; // a package, not a file of the site
|
|
1372
|
+
}
|
|
1373
|
+
for (const candidate of [
|
|
1374
|
+
base,
|
|
1375
|
+
`${base}.tsx`,
|
|
1376
|
+
`${base}.ts`,
|
|
1377
|
+
`${base}.jsx`,
|
|
1378
|
+
`${base}.js`,
|
|
1379
|
+
path.join(base, "index.tsx"),
|
|
1380
|
+
path.join(base, "index.ts"),
|
|
1381
|
+
]) {
|
|
1382
|
+
const code = files.get(candidate);
|
|
1383
|
+
if (code !== undefined) return { code, filePath: candidate };
|
|
1384
|
+
}
|
|
1385
|
+
return null;
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Pick a free key dedicated to this page's meta, derived from the shared key
|
|
1391
|
+
* ("atelier.title" + "title" -> "atelier.meta.title"). Never reuses a key that already
|
|
1392
|
+
* exists in the locales or is referenced by the code.
|
|
1393
|
+
*/
|
|
1394
|
+
private async allocateMetaKey(
|
|
1395
|
+
sharedKey: string,
|
|
1396
|
+
fieldName: string,
|
|
1397
|
+
sources: string,
|
|
1398
|
+
namespace: string,
|
|
1399
|
+
): Promise<string> {
|
|
1400
|
+
const base = sharedKey.split(".")[0] || "page";
|
|
1401
|
+
const languages = await this.listLanguages();
|
|
1402
|
+
const documents = await Promise.all(languages.map((lang) => this.readLocale(lang, namespace)));
|
|
1403
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
1404
|
+
const candidate =
|
|
1405
|
+
attempt === 0 ? `${base}.meta.${fieldName}` : `${base}.meta.${fieldName}${attempt + 1}`;
|
|
1406
|
+
const taken =
|
|
1407
|
+
countKeyUsages(sources, candidate) > 0 ||
|
|
1408
|
+
documents.some((doc) => readLocaleValue(doc, candidate) !== null);
|
|
1409
|
+
if (!taken) return candidate;
|
|
1410
|
+
}
|
|
1411
|
+
return `${base}.meta.${fieldName}.${Date.now()}`;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Resolve a react-router route id ("routes/_layout._index", "root") to its source file
|
|
1416
|
+
* inside the workspace. Never escapes `<projectRoot>/app`.
|
|
1417
|
+
*/
|
|
1418
|
+
private async resolveRouteFile(
|
|
1419
|
+
routeId: string,
|
|
1420
|
+
): Promise<{ filePath: string; relativeFile: string } | null> {
|
|
1421
|
+
const appDir = path.join(this.projectRoot, "app");
|
|
1422
|
+
for (const ext of ["tsx", "ts", "jsx", "js"]) {
|
|
1423
|
+
const filePath = path.join(appDir, `${routeId}.${ext}`);
|
|
1424
|
+
const resolved = path.resolve(filePath);
|
|
1425
|
+
if (resolved !== appDir && !resolved.startsWith(appDir + path.sep)) continue;
|
|
1426
|
+
try {
|
|
1427
|
+
await fs.access(resolved);
|
|
1428
|
+
return { filePath: resolved, relativeFile: path.relative(this.projectRoot, resolved) };
|
|
1429
|
+
} catch {
|
|
1430
|
+
// try next extension
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
return null;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
495
1436
|
/** Parse "<relativeFile>:<offset>" into an absolute path + numeric offset. */
|
|
496
1437
|
private resolveArrayId(arrayId: string): { filePath: string; relativeFile: string; offset: number } | null {
|
|
497
1438
|
const sep = arrayId.lastIndexOf(":");
|