@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
package/src/page-meta.ts
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { parseSync } from "oxc-parser";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Static analysis of a route module's page metadata (browser-tab title, description,
|
|
5
|
+
* keywords, robots indexing).
|
|
6
|
+
*
|
|
7
|
+
* Routes generated by the AI assistant render their meta with React 19's native hoisting
|
|
8
|
+
* and take the text from i18next, e.g.:
|
|
9
|
+
*
|
|
10
|
+
* export async function loader({ context }) {
|
|
11
|
+
* const i18n = getInstance(context);
|
|
12
|
+
* return { title: i18n.t("about.meta.title"), description: i18n.t("about.meta.description") };
|
|
13
|
+
* }
|
|
14
|
+
* export default function AboutPage({ loaderData }) {
|
|
15
|
+
* const { title, description } = loaderData;
|
|
16
|
+
* return (<><title>{title}</title><meta name="description" content={description} />…
|
|
17
|
+
*
|
|
18
|
+
* so editing "the page title" really means editing a translation key. This module resolves
|
|
19
|
+
* each meta value back to its origin — an i18n key, a plain string literal, or something we
|
|
20
|
+
* refuse to touch — and locates the byte ranges the editor needs to rewrite.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface AstNode {
|
|
24
|
+
type: string;
|
|
25
|
+
start: number;
|
|
26
|
+
end: number;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where a meta value ultimately comes from. */
|
|
31
|
+
export type MetaValueOrigin =
|
|
32
|
+
/** `i18n.t("key")` in the route's loader — the text lives in the locale files. */
|
|
33
|
+
| {
|
|
34
|
+
kind: "i18n";
|
|
35
|
+
key: string;
|
|
36
|
+
namespace: string;
|
|
37
|
+
/** Bounds of the key string, quotes excluded, so it can be repointed to another key. */
|
|
38
|
+
keyStart: number;
|
|
39
|
+
keyEnd: number;
|
|
40
|
+
}
|
|
41
|
+
/** A string literal in the source (JSX attribute, JSX text or loader value). */
|
|
42
|
+
| { kind: "literal"; start: number; end: number; value: string; quote: string }
|
|
43
|
+
/** The element is not in the route at all. */
|
|
44
|
+
| { kind: "absent" }
|
|
45
|
+
/**
|
|
46
|
+
* A prop of the component being analyzed. Only produced while looking inside a shared
|
|
47
|
+
* SEO component; the caller substitutes the value the route passes for it.
|
|
48
|
+
*/
|
|
49
|
+
| { kind: "prop"; name: string }
|
|
50
|
+
/** Computed at runtime (database row, several keys combined…) — read-only. */
|
|
51
|
+
| { kind: "unsupported"; reason: string };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A piece of a composed value: either literal text from the template, or another dynamic
|
|
55
|
+
* part that the caller resolves for display (e.g. the site name appended to every title).
|
|
56
|
+
*/
|
|
57
|
+
export type MetaSegment = { text: string } | { origin: MetaValueOrigin };
|
|
58
|
+
|
|
59
|
+
export interface MetaElement {
|
|
60
|
+
origin: MetaValueOrigin;
|
|
61
|
+
/** What is rendered around the editable part, e.g. `{`${title} - ${tagline}`}`. */
|
|
62
|
+
prefixSegments: MetaSegment[];
|
|
63
|
+
suffixSegments: MetaSegment[];
|
|
64
|
+
/** Bounds of the whole JSX element, when the route has one. */
|
|
65
|
+
elementStart?: number;
|
|
66
|
+
elementEnd?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RouteMetaAnalysis {
|
|
70
|
+
title: MetaElement;
|
|
71
|
+
description: MetaElement;
|
|
72
|
+
keywords: MetaElement;
|
|
73
|
+
robots: MetaElement;
|
|
74
|
+
/** True when the route renders at least one meta element as JSX. */
|
|
75
|
+
hasJsxMeta: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Offset a new `<meta …/>` sibling can be inserted at (right after the last existing
|
|
78
|
+
* meta element), plus the indentation of the line it sits on.
|
|
79
|
+
*/
|
|
80
|
+
insertOffset: number | null;
|
|
81
|
+
insertIndent: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ABSENT: MetaElement = { origin: { kind: "absent" }, prefixSegments: [], suffixSegments: [] };
|
|
85
|
+
|
|
86
|
+
function walk(node: unknown, visit: (n: AstNode) => void): void {
|
|
87
|
+
if (!node || typeof node !== "object") return;
|
|
88
|
+
if (Array.isArray(node)) {
|
|
89
|
+
for (const child of node) walk(child, visit);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const n = node as AstNode;
|
|
93
|
+
if (typeof n.type === "string") visit(n);
|
|
94
|
+
for (const key in n) {
|
|
95
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
96
|
+
const value = n[key];
|
|
97
|
+
if (value && typeof value === "object") walk(value, visit);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Value of a JSX attribute that is a plain string, e.g. `name="description"`. */
|
|
102
|
+
function jsxAttributeString(element: AstNode, attributeName: string): string | null {
|
|
103
|
+
const opening = element.openingElement as AstNode | undefined;
|
|
104
|
+
for (const attr of (opening?.attributes as AstNode[]) ?? []) {
|
|
105
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
106
|
+
const name = attr.name as AstNode;
|
|
107
|
+
if (name?.type !== "JSXIdentifier" || name.name !== attributeName) continue;
|
|
108
|
+
const value = attr.value as AstNode | null;
|
|
109
|
+
if (value?.type === "Literal" && typeof value.value === "string") return value.value;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function jsxAttributeNode(element: AstNode, attributeName: string): AstNode | null {
|
|
116
|
+
const opening = element.openingElement as AstNode | undefined;
|
|
117
|
+
for (const attr of (opening?.attributes as AstNode[]) ?? []) {
|
|
118
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
119
|
+
const name = attr.name as AstNode;
|
|
120
|
+
if (name?.type === "JSXIdentifier" && name.name === attributeName) return attr;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A string literal node → an editable origin covering the text between its quotes. */
|
|
126
|
+
function literalOrigin(code: string, node: AstNode): MetaValueOrigin {
|
|
127
|
+
const quote = code[node.start];
|
|
128
|
+
const quoted = quote === '"' || quote === "'" || quote === "`";
|
|
129
|
+
return {
|
|
130
|
+
kind: "literal",
|
|
131
|
+
start: quoted ? node.start + 1 : node.start,
|
|
132
|
+
end: quoted ? node.end - 1 : node.end,
|
|
133
|
+
value: node.value as string,
|
|
134
|
+
quote: quoted ? quote : '"',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read `t("key")` / `i18n.t("ns:key", { ns })`. Returns null when the call is not a
|
|
140
|
+
* translation lookup we can address (dynamic key, interpolated values…).
|
|
141
|
+
*/
|
|
142
|
+
function readTranslationCall(node: AstNode): MetaValueOrigin | null {
|
|
143
|
+
if (node.type !== "CallExpression") return null;
|
|
144
|
+
const callee = node.callee as AstNode;
|
|
145
|
+
const isT =
|
|
146
|
+
(callee.type === "Identifier" && callee.name === "t") ||
|
|
147
|
+
(callee.type === "MemberExpression" &&
|
|
148
|
+
!callee.computed &&
|
|
149
|
+
(callee.property as AstNode)?.type === "Identifier" &&
|
|
150
|
+
((callee.property as AstNode).name as string) === "t");
|
|
151
|
+
if (!isT) return null;
|
|
152
|
+
|
|
153
|
+
const args = (node.arguments as AstNode[]) ?? [];
|
|
154
|
+
const first = args[0];
|
|
155
|
+
if (first?.type !== "Literal" || typeof first.value !== "string") return null;
|
|
156
|
+
|
|
157
|
+
let namespace = "translation";
|
|
158
|
+
let key = first.value;
|
|
159
|
+
const colon = key.indexOf(":");
|
|
160
|
+
if (colon > 0) {
|
|
161
|
+
namespace = key.slice(0, colon);
|
|
162
|
+
key = key.slice(colon + 1);
|
|
163
|
+
}
|
|
164
|
+
// A second argument is only acceptable when it merely selects the namespace: anything
|
|
165
|
+
// else (interpolation values, counts) means the stored string is not what is displayed.
|
|
166
|
+
const second = args[1];
|
|
167
|
+
if (second) {
|
|
168
|
+
if (second.type !== "ObjectExpression") return null;
|
|
169
|
+
const props = (second.properties as AstNode[]) ?? [];
|
|
170
|
+
if (props.length !== 1) return null;
|
|
171
|
+
const prop = props[0];
|
|
172
|
+
if (prop.type !== "Property" || prop.computed) return null;
|
|
173
|
+
const propKey = prop.key as AstNode;
|
|
174
|
+
const propValue = prop.value as AstNode;
|
|
175
|
+
const propName = propKey.type === "Identifier" ? propKey.name : propKey.value;
|
|
176
|
+
if (propName !== "ns" || propValue.type !== "Literal" || typeof propValue.value !== "string") {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
namespace = propValue.value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { kind: "i18n", key, namespace, keyStart: first.start + 1, keyEnd: first.end - 1 };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface TraceContext {
|
|
186
|
+
code: string;
|
|
187
|
+
program: AstNode;
|
|
188
|
+
/**
|
|
189
|
+
* "route": identifiers come from the loader through `loaderData`.
|
|
190
|
+
* "component": identifiers are local constants or props of the component itself.
|
|
191
|
+
*/
|
|
192
|
+
mode: "route" | "component";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Initializer of a plain `const x = …` declaration, when the module has one. */
|
|
196
|
+
function localDeclaration(ctx: TraceContext, identifier: string): AstNode | null {
|
|
197
|
+
let found: AstNode | null = null;
|
|
198
|
+
walk(ctx.program, (node) => {
|
|
199
|
+
if (found || node.type !== "VariableDeclarator") return;
|
|
200
|
+
const id = node.id as AstNode;
|
|
201
|
+
if (id?.type === "Identifier" && id.name === identifier) found = (node.init as AstNode) ?? null;
|
|
202
|
+
});
|
|
203
|
+
return found;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The key an object pattern binds to `identifier`, following `{ a: b }` and `{ a = 1 }`. */
|
|
207
|
+
function patternKeyFor(pattern: AstNode, identifier: string): string | null {
|
|
208
|
+
for (const prop of (pattern.properties as AstNode[]) ?? []) {
|
|
209
|
+
if (prop.type !== "Property") continue;
|
|
210
|
+
const key = prop.key as AstNode;
|
|
211
|
+
let value = prop.value as AstNode;
|
|
212
|
+
// `{ image = "/default.png" }` binds through an assignment pattern.
|
|
213
|
+
if (value?.type === "AssignmentPattern") value = value.left as AstNode;
|
|
214
|
+
if (value?.type === "Identifier" && value.name === identifier && key?.type === "Identifier") {
|
|
215
|
+
return key.name as string;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Property name an identifier was destructured from, e.g. `const { title } = loaderData`. */
|
|
222
|
+
function destructuredSourceName(ctx: TraceContext, identifier: string): string | null {
|
|
223
|
+
let found: string | null = null;
|
|
224
|
+
walk(ctx.program, (node) => {
|
|
225
|
+
if (found || node.type !== "VariableDeclarator") return;
|
|
226
|
+
const id = node.id as AstNode;
|
|
227
|
+
if (id?.type !== "ObjectPattern") return;
|
|
228
|
+
found = patternKeyFor(id, identifier);
|
|
229
|
+
});
|
|
230
|
+
return found;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Prop name an identifier is bound to by a component's signature, e.g.
|
|
235
|
+
* `function SeoHead({ title }: Props)`. Props arrive as a parameter pattern, not as a
|
|
236
|
+
* variable declaration, so they need their own lookup.
|
|
237
|
+
*/
|
|
238
|
+
function parameterPropName(ctx: TraceContext, identifier: string): string | null {
|
|
239
|
+
let found: string | null = null;
|
|
240
|
+
walk(ctx.program, (node) => {
|
|
241
|
+
if (found) return;
|
|
242
|
+
if (
|
|
243
|
+
node.type !== "FunctionDeclaration" &&
|
|
244
|
+
node.type !== "ArrowFunctionExpression" &&
|
|
245
|
+
node.type !== "FunctionExpression"
|
|
246
|
+
) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const first = ((node.params as AstNode[]) ?? [])[0];
|
|
250
|
+
if (first?.type === "ObjectPattern") found = patternKeyFor(first, identifier);
|
|
251
|
+
});
|
|
252
|
+
return found;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The route's exported `loader` function, whatever declaration form it uses. */
|
|
256
|
+
function findLoader(ctx: TraceContext): AstNode | null {
|
|
257
|
+
const body = (ctx.program.body as AstNode[]) ?? [];
|
|
258
|
+
for (const statement of body) {
|
|
259
|
+
if (statement.type !== "ExportNamedDeclaration") continue;
|
|
260
|
+
const declaration = statement.declaration as AstNode | null;
|
|
261
|
+
if (!declaration) continue;
|
|
262
|
+
if (declaration.type === "FunctionDeclaration") {
|
|
263
|
+
const id = declaration.id as AstNode | null;
|
|
264
|
+
if (id?.type === "Identifier" && id.name === "loader") return declaration;
|
|
265
|
+
} else if (declaration.type === "VariableDeclaration") {
|
|
266
|
+
for (const decl of (declaration.declarations as AstNode[]) ?? []) {
|
|
267
|
+
const id = decl.id as AstNode;
|
|
268
|
+
if (id?.type === "Identifier" && id.name === "loader") return (decl.init as AstNode) ?? null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Value the loader returns for `propertyName`, across every `return { … }` it contains. */
|
|
276
|
+
function loaderReturnValue(loader: AstNode, propertyName: string): AstNode | null {
|
|
277
|
+
let found: AstNode | null = null;
|
|
278
|
+
walk(loader, (node) => {
|
|
279
|
+
if (found || node.type !== "ReturnStatement") return;
|
|
280
|
+
const argument = node.argument as AstNode | null;
|
|
281
|
+
if (argument?.type !== "ObjectExpression") return;
|
|
282
|
+
for (const prop of (argument.properties as AstNode[]) ?? []) {
|
|
283
|
+
if (prop.type !== "Property" || prop.computed) continue;
|
|
284
|
+
const key = prop.key as AstNode;
|
|
285
|
+
const name = key?.type === "Identifier" ? key.name : key?.type === "Literal" ? key.value : null;
|
|
286
|
+
if (name === propertyName) {
|
|
287
|
+
found = prop.value as AstNode;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
return found;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const unsupportedElement = (reason: string): MetaElement => ({
|
|
296
|
+
origin: { kind: "unsupported", reason },
|
|
297
|
+
prefixSegments: [],
|
|
298
|
+
suffixSegments: [],
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Follow a component identifier back to the loader value that feeds it. The loader may
|
|
303
|
+
* itself compose several translations, so this returns a full element rather than a single
|
|
304
|
+
* origin.
|
|
305
|
+
*/
|
|
306
|
+
function traceIdentifier(ctx: TraceContext, identifier: string, depth: number): MetaElement {
|
|
307
|
+
// Guards against a self-referencing declaration chain.
|
|
308
|
+
if (depth > 4) return unsupportedElement("This value is computed by code");
|
|
309
|
+
|
|
310
|
+
// `const fullTitle = `${title} — Acme`` and friends.
|
|
311
|
+
const local = localDeclaration(ctx, identifier);
|
|
312
|
+
if (local) return resolveExpression(ctx, local, depth + 1);
|
|
313
|
+
|
|
314
|
+
// Inside a shared SEO component the value is a prop: hand it back to the caller, which
|
|
315
|
+
// knows what the route passes for it.
|
|
316
|
+
if (ctx.mode === "component") {
|
|
317
|
+
const propName = parameterPropName(ctx, identifier) ?? destructuredSourceName(ctx, identifier);
|
|
318
|
+
if (!propName) return unsupportedElement("This value is computed by code");
|
|
319
|
+
return { origin: { kind: "prop", name: propName }, prefixSegments: [], suffixSegments: [] };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const sourceName = destructuredSourceName(ctx, identifier);
|
|
323
|
+
if (!sourceName) return unsupportedElement("This value is computed by code");
|
|
324
|
+
|
|
325
|
+
const loader = findLoader(ctx);
|
|
326
|
+
if (!loader) return unsupportedElement("This value is computed by code");
|
|
327
|
+
const value = loaderReturnValue(loader, sourceName);
|
|
328
|
+
if (!value) return unsupportedElement("This value is computed by code");
|
|
329
|
+
|
|
330
|
+
const translation = readTranslationCall(value);
|
|
331
|
+
if (translation) return { origin: translation, prefixSegments: [], suffixSegments: [] };
|
|
332
|
+
if (value.type === "Literal" && typeof value.value === "string") {
|
|
333
|
+
return { origin: literalOrigin(ctx.code, value), prefixSegments: [], suffixSegments: [] };
|
|
334
|
+
}
|
|
335
|
+
if (value.type === "TemplateLiteral") return resolveExpression(ctx, value, depth + 1);
|
|
336
|
+
return unsupportedElement("This value is built from other content");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Resolve any expression used as a meta value, keeping the read-only parts around it. */
|
|
340
|
+
function resolveExpression(ctx: TraceContext, expression: AstNode, depth = 0): MetaElement {
|
|
341
|
+
if (expression.type === "Literal" && typeof expression.value === "string") {
|
|
342
|
+
return { origin: literalOrigin(ctx.code, expression), prefixSegments: [], suffixSegments: [] };
|
|
343
|
+
}
|
|
344
|
+
if (expression.type === "Identifier") {
|
|
345
|
+
return traceIdentifier(ctx, expression.name as string, depth);
|
|
346
|
+
}
|
|
347
|
+
// `i18n.t("key")` used inline, e.g. in a title composed inside the loader.
|
|
348
|
+
const translation = readTranslationCall(expression);
|
|
349
|
+
if (translation) return { origin: translation, prefixSegments: [], suffixSegments: [] };
|
|
350
|
+
if (expression.type === "TemplateLiteral") {
|
|
351
|
+
const quasis = (expression.quasis as AstNode[]) ?? [];
|
|
352
|
+
const expressions = (expression.expressions as AstNode[]) ?? [];
|
|
353
|
+
if (expressions.length === 0 && quasis.length === 1) {
|
|
354
|
+
const quasi = quasis[0];
|
|
355
|
+
const cooked = (quasi.value as { cooked?: string })?.cooked ?? "";
|
|
356
|
+
return {
|
|
357
|
+
origin: { kind: "literal", start: quasi.start, end: quasi.end, value: cooked, quote: "`" },
|
|
358
|
+
prefixSegments: [],
|
|
359
|
+
suffixSegments: [],
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Interleave the literal chunks with the interpolated values, then take the first part
|
|
364
|
+
// we can actually address as the editable one. A title built from several translations
|
|
365
|
+
// (`${pageTitle} - ${siteTagline}`) still has one part that belongs to this page; the
|
|
366
|
+
// rest becomes read-only context so the user sees the whole rendered string.
|
|
367
|
+
const segments: MetaSegment[] = [];
|
|
368
|
+
quasis.forEach((quasi, index) => {
|
|
369
|
+
segments.push({ text: (quasi.value as { cooked?: string })?.cooked ?? "" });
|
|
370
|
+
const interpolated = expressions[index];
|
|
371
|
+
if (!interpolated) return;
|
|
372
|
+
// Each interpolation can itself be composed — flatten its own parts in place.
|
|
373
|
+
const resolved = resolveExpression(ctx, interpolated, depth + 1);
|
|
374
|
+
segments.push(...resolved.prefixSegments, { origin: resolved.origin }, ...resolved.suffixSegments);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const editableAt = segments.findIndex(
|
|
378
|
+
(segment) =>
|
|
379
|
+
"origin" in segment &&
|
|
380
|
+
(segment.origin.kind === "i18n" ||
|
|
381
|
+
segment.origin.kind === "literal" ||
|
|
382
|
+
segment.origin.kind === "prop"),
|
|
383
|
+
);
|
|
384
|
+
if (editableAt === -1) {
|
|
385
|
+
return {
|
|
386
|
+
origin: { kind: "unsupported", reason: "This value is computed by code" },
|
|
387
|
+
prefixSegments: [],
|
|
388
|
+
suffixSegments: [],
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
origin: (segments[editableAt] as { origin: MetaValueOrigin }).origin,
|
|
393
|
+
prefixSegments: segments.slice(0, editableAt),
|
|
394
|
+
suffixSegments: segments.slice(editableAt + 1),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
origin: { kind: "unsupported", reason: "This value is computed by code" },
|
|
399
|
+
prefixSegments: [],
|
|
400
|
+
suffixSegments: [],
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Read `<title>…</title>` children. */
|
|
405
|
+
function analyzeTitleElement(ctx: TraceContext, element: AstNode): MetaElement {
|
|
406
|
+
const children = ((element.children as AstNode[]) ?? []).filter(
|
|
407
|
+
(child) => child.type !== "JSXText" || String(child.value ?? "").trim() !== "",
|
|
408
|
+
);
|
|
409
|
+
if (children.length === 1) {
|
|
410
|
+
const child = children[0];
|
|
411
|
+
if (child.type === "JSXText") {
|
|
412
|
+
return {
|
|
413
|
+
origin: {
|
|
414
|
+
kind: "literal",
|
|
415
|
+
start: child.start,
|
|
416
|
+
end: child.end,
|
|
417
|
+
value: String(child.value ?? ""),
|
|
418
|
+
quote: '"',
|
|
419
|
+
},
|
|
420
|
+
prefixSegments: [],
|
|
421
|
+
suffixSegments: [],
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (child.type === "JSXExpressionContainer") {
|
|
425
|
+
return resolveExpression(ctx, child.expression as AstNode);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
origin: { kind: "unsupported", reason: "This title is computed by code" },
|
|
430
|
+
prefixSegments: [],
|
|
431
|
+
suffixSegments: [],
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Read the `content` attribute of a `<meta …/>` element. */
|
|
436
|
+
function analyzeMetaElement(ctx: TraceContext, element: AstNode): MetaElement {
|
|
437
|
+
const attribute = jsxAttributeNode(element, "content");
|
|
438
|
+
const value = attribute?.value as AstNode | null | undefined;
|
|
439
|
+
if (!value) {
|
|
440
|
+
return {
|
|
441
|
+
origin: { kind: "unsupported", reason: "This tag has no content" },
|
|
442
|
+
prefixSegments: [],
|
|
443
|
+
suffixSegments: [],
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (value.type === "Literal" && typeof value.value === "string") {
|
|
447
|
+
return { origin: literalOrigin(ctx.code, value), prefixSegments: [], suffixSegments: [] };
|
|
448
|
+
}
|
|
449
|
+
if (value.type === "JSXExpressionContainer") {
|
|
450
|
+
return resolveExpression(ctx, value.expression as AstNode);
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
origin: { kind: "unsupported", reason: "This value is computed by code" },
|
|
454
|
+
prefixSegments: [],
|
|
455
|
+
suffixSegments: [],
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Locate and resolve the four meta values a route can render as JSX.
|
|
461
|
+
*/
|
|
462
|
+
|
|
463
|
+
/** Import specifier a local identifier (a component name) was imported from. */
|
|
464
|
+
function importSpecifierOf(program: AstNode, name: string): string | null {
|
|
465
|
+
for (const statement of (program.body as AstNode[]) ?? []) {
|
|
466
|
+
if (statement.type !== "ImportDeclaration") continue;
|
|
467
|
+
for (const spec of (statement.specifiers as AstNode[]) ?? []) {
|
|
468
|
+
const local = spec.local as AstNode | undefined;
|
|
469
|
+
if (local?.type === "Identifier" && local.name === name) {
|
|
470
|
+
const source = statement.source as AstNode;
|
|
471
|
+
return typeof source?.value === "string" ? source.value : null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** JSX attributes of an element, by name, ignoring spreads. */
|
|
479
|
+
function jsxAttributes(element: AstNode): Map<string, AstNode> {
|
|
480
|
+
const attributes = new Map<string, AstNode>();
|
|
481
|
+
const opening = element.openingElement as AstNode | undefined;
|
|
482
|
+
for (const attr of (opening?.attributes as AstNode[]) ?? []) {
|
|
483
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
484
|
+
const name = attr.name as AstNode;
|
|
485
|
+
if (name?.type === "JSXIdentifier") attributes.set(name.name as string, attr);
|
|
486
|
+
}
|
|
487
|
+
return attributes;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Rendered components, in source order, that could stand in for the meta tags. */
|
|
491
|
+
function findComponentElements(program: AstNode): AstNode[] {
|
|
492
|
+
const found: AstNode[] = [];
|
|
493
|
+
walk(program, (node) => {
|
|
494
|
+
if (node.type !== "JSXElement") return;
|
|
495
|
+
const name = (node.openingElement as AstNode)?.name as AstNode | undefined;
|
|
496
|
+
// Components are capitalized; lowercase names are plain HTML tags.
|
|
497
|
+
if (name?.type === "JSXIdentifier" && /^[A-Z]/.test(name.name as string)) found.push(node);
|
|
498
|
+
});
|
|
499
|
+
return found;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Resolve the value the route passes for one of the component's props. The value is read in
|
|
504
|
+
* the ROUTE's context, so a prop fed by the loader still resolves to its translation key.
|
|
505
|
+
*/
|
|
506
|
+
function resolveProp(routeCtx: TraceContext, attributes: Map<string, AstNode>, name: string): MetaElement {
|
|
507
|
+
const attribute = attributes.get(name);
|
|
508
|
+
const value = attribute?.value as AstNode | null | undefined;
|
|
509
|
+
if (!value) return unsupportedElement("This value is set by the page's SEO component");
|
|
510
|
+
if (value.type === "Literal" && typeof value.value === "string") {
|
|
511
|
+
return { origin: literalOrigin(routeCtx.code, value), prefixSegments: [], suffixSegments: [] };
|
|
512
|
+
}
|
|
513
|
+
if (value.type === "JSXExpressionContainer") {
|
|
514
|
+
return resolveExpression(routeCtx, value.expression as AstNode, 0);
|
|
515
|
+
}
|
|
516
|
+
return unsupportedElement("This value is computed by code");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Replace every `prop` origin with what the route passes, keeping the surrounding parts. */
|
|
520
|
+
function substituteProps(
|
|
521
|
+
element: MetaElement,
|
|
522
|
+
routeCtx: TraceContext,
|
|
523
|
+
attributes: Map<string, AstNode>,
|
|
524
|
+
): MetaElement {
|
|
525
|
+
const substituteSegments = (segments: MetaSegment[]): MetaSegment[] =>
|
|
526
|
+
segments.flatMap((segment) => {
|
|
527
|
+
if (!("origin" in segment) || segment.origin.kind !== "prop") return [segment];
|
|
528
|
+
const resolved = resolveProp(routeCtx, attributes, segment.origin.name);
|
|
529
|
+
return [...resolved.prefixSegments, { origin: resolved.origin }, ...resolved.suffixSegments];
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const prefixSegments = substituteSegments(element.prefixSegments);
|
|
533
|
+
const suffixSegments = substituteSegments(element.suffixSegments);
|
|
534
|
+
|
|
535
|
+
if (element.origin.kind !== "prop") {
|
|
536
|
+
// The value lives in the shared component, so it is the same on every page: showing it
|
|
537
|
+
// as editable here would silently rewrite the whole site.
|
|
538
|
+
const origin: MetaValueOrigin =
|
|
539
|
+
element.origin.kind === "literal"
|
|
540
|
+
? { kind: "unsupported", reason: "This text comes from the page's SEO component" }
|
|
541
|
+
: element.origin;
|
|
542
|
+
return { ...element, origin, prefixSegments, suffixSegments };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const resolved = resolveProp(routeCtx, attributes, element.origin.name);
|
|
546
|
+
return {
|
|
547
|
+
...element,
|
|
548
|
+
origin: resolved.origin,
|
|
549
|
+
prefixSegments: [...prefixSegments, ...resolved.prefixSegments],
|
|
550
|
+
suffixSegments: [...resolved.suffixSegments, ...suffixSegments],
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Follow a component the route delegates its metadata to, and express its meta values in
|
|
556
|
+
* terms of what the route passes it.
|
|
557
|
+
*/
|
|
558
|
+
function analyzeDelegate(
|
|
559
|
+
routeCtx: TraceContext,
|
|
560
|
+
program: AstNode,
|
|
561
|
+
loadModule: ModuleLoader,
|
|
562
|
+
): RouteMetaAnalysis | null {
|
|
563
|
+
for (const element of findComponentElements(program)) {
|
|
564
|
+
const componentName = ((element.openingElement as AstNode).name as AstNode).name as string;
|
|
565
|
+
const specifier = importSpecifierOf(program, componentName);
|
|
566
|
+
if (!specifier) continue;
|
|
567
|
+
const module = loadModule(specifier);
|
|
568
|
+
if (!module) continue;
|
|
569
|
+
|
|
570
|
+
const moduleProgram = parseSync(module.filePath, module.code, { sourceType: "module" })
|
|
571
|
+
.program as unknown as AstNode;
|
|
572
|
+
const moduleElements = findMetaElements(moduleProgram);
|
|
573
|
+
if (!moduleElements.title && !moduleElements.description) continue;
|
|
574
|
+
|
|
575
|
+
const moduleCtx: TraceContext = { code: module.code, program: moduleProgram, mode: "component" };
|
|
576
|
+
const attributes = jsxAttributes(element);
|
|
577
|
+
const read = (
|
|
578
|
+
node: AstNode | undefined,
|
|
579
|
+
analyze: (ctx: TraceContext, el: AstNode) => MetaElement,
|
|
580
|
+
): MetaElement => (node ? substituteProps(analyze(moduleCtx, node), routeCtx, attributes) : ABSENT);
|
|
581
|
+
|
|
582
|
+
// Keywords and robots are page-specific, so they are read from — and written to — the
|
|
583
|
+
// route itself, next to the component, rather than to the shared component.
|
|
584
|
+
const routeElements = findMetaElements(program);
|
|
585
|
+
const routeRead = (node: AstNode | undefined): MetaElement =>
|
|
586
|
+
node
|
|
587
|
+
? { ...analyzeMetaElement(routeCtx, node), elementStart: node.start, elementEnd: node.end }
|
|
588
|
+
: ABSENT;
|
|
589
|
+
|
|
590
|
+
const lineStart = routeCtx.code.lastIndexOf("\n", element.start) + 1;
|
|
591
|
+
return {
|
|
592
|
+
title: read(moduleElements.title, analyzeTitleElement),
|
|
593
|
+
description: read(moduleElements.description, analyzeMetaElement),
|
|
594
|
+
keywords: routeRead(routeElements.keywords),
|
|
595
|
+
robots: routeRead(routeElements.robots),
|
|
596
|
+
hasJsxMeta: true,
|
|
597
|
+
insertOffset: element.end,
|
|
598
|
+
insertIndent: routeCtx.code.slice(lineStart, element.start).match(/^[ \t]*/)?.[0] ?? "",
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The four meta elements a module renders, if any. */
|
|
605
|
+
function findMetaElements(program: AstNode) {
|
|
606
|
+
const elements: Partial<Record<"title" | "description" | "keywords" | "robots", AstNode>> = {};
|
|
607
|
+
walk(program, (node) => {
|
|
608
|
+
if (node.type !== "JSXElement") return;
|
|
609
|
+
const name = (node.openingElement as AstNode)?.name as AstNode | undefined;
|
|
610
|
+
if (name?.type !== "JSXIdentifier") return;
|
|
611
|
+
if (name.name === "title") {
|
|
612
|
+
elements.title ??= node;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (name.name !== "meta") return;
|
|
616
|
+
const metaName = jsxAttributeString(node, "name");
|
|
617
|
+
if (metaName === "description" || metaName === "keywords" || metaName === "robots") {
|
|
618
|
+
elements[metaName] ??= node;
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
return elements;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Reads a module (by import specifier) so a shared SEO component can be followed. */
|
|
625
|
+
export type ModuleLoader = (specifier: string) => { code: string; filePath: string } | null;
|
|
626
|
+
|
|
627
|
+
export function analyzeRouteMeta(
|
|
628
|
+
code: string,
|
|
629
|
+
filePath: string,
|
|
630
|
+
loadModule?: ModuleLoader,
|
|
631
|
+
): RouteMetaAnalysis {
|
|
632
|
+
const ast = parseSync(filePath, code, { sourceType: "module" });
|
|
633
|
+
const program = ast.program as unknown as AstNode;
|
|
634
|
+
const ctx: TraceContext = { code, program, mode: "route" };
|
|
635
|
+
|
|
636
|
+
const elements = findMetaElements(program);
|
|
637
|
+
|
|
638
|
+
const build = (
|
|
639
|
+
node: AstNode | undefined,
|
|
640
|
+
read: (ctx: TraceContext, element: AstNode) => MetaElement,
|
|
641
|
+
): MetaElement => {
|
|
642
|
+
if (!node) return ABSENT;
|
|
643
|
+
return { ...read(ctx, node), elementStart: node.start, elementEnd: node.end };
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const title = build(elements.title, analyzeTitleElement);
|
|
647
|
+
const description = build(elements.description, analyzeMetaElement);
|
|
648
|
+
const keywords = build(elements.keywords, analyzeMetaElement);
|
|
649
|
+
const robots = build(elements.robots, analyzeMetaElement);
|
|
650
|
+
|
|
651
|
+
// Nothing inline: the route may hand its metadata to a shared component
|
|
652
|
+
// (`<SeoHead title={title} description={description} />`), so follow it.
|
|
653
|
+
const delegated =
|
|
654
|
+
!title.elementStart && !description.elementStart && loadModule
|
|
655
|
+
? analyzeDelegate(ctx, program, loadModule)
|
|
656
|
+
: null;
|
|
657
|
+
if (delegated) return delegated;
|
|
658
|
+
|
|
659
|
+
// New tags go after the last meta element already there, so they stay grouped.
|
|
660
|
+
const present = [title, description, keywords, robots].filter((el) => el.elementEnd !== undefined);
|
|
661
|
+
const insertOffset = present.length ? Math.max(...present.map((el) => el.elementEnd as number)) : null;
|
|
662
|
+
let insertIndent = "";
|
|
663
|
+
if (insertOffset !== null) {
|
|
664
|
+
const anchor = Math.min(...present.map((el) => el.elementStart as number));
|
|
665
|
+
const lineStart = code.lastIndexOf("\n", anchor) + 1;
|
|
666
|
+
insertIndent = code.slice(lineStart, anchor).match(/^[ \t]*/)?.[0] ?? "";
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
title,
|
|
671
|
+
description,
|
|
672
|
+
keywords,
|
|
673
|
+
robots,
|
|
674
|
+
hasJsxMeta: present.length > 0,
|
|
675
|
+
insertOffset,
|
|
676
|
+
insertIndent,
|
|
677
|
+
};
|
|
678
|
+
}
|