@upstart.gg/vite-plugins 0.0.37
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/vite-plugin-upstart-attrs.d.ts +29 -0
- package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-attrs.js +323 -0
- package/dist/vite-plugin-upstart-attrs.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/plugin.d.ts +15 -0
- package/dist/vite-plugin-upstart-editor/plugin.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-editor/plugin.js +55 -0
- package/dist/vite-plugin-upstart-editor/plugin.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts +12 -0
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +57 -0
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.d.ts +12 -0
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +91 -0
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.d.ts +22 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.js +62 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts +15 -0
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +292 -0
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +126 -0
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/types.js +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/utils.d.ts +15 -0
- package/dist/vite-plugin-upstart-editor/runtime/utils.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/utils.js +26 -0
- package/dist/vite-plugin-upstart-editor/runtime/utils.js.map +1 -0
- package/dist/vite-plugin-upstart-theme.d.ts +22 -0
- package/dist/vite-plugin-upstart-theme.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-theme.js +179 -0
- package/dist/vite-plugin-upstart-theme.js.map +1 -0
- package/package.json +63 -0
- package/src/tests/fixtures/routes/default-layout.tsx +10 -0
- package/src/tests/fixtures/routes/dynamic-route.tsx +10 -0
- package/src/tests/fixtures/routes/missing-attributes.tsx +8 -0
- package/src/tests/fixtures/routes/missing-path.tsx +9 -0
- package/src/tests/fixtures/routes/valid-full.tsx +15 -0
- package/src/tests/fixtures/routes/valid-minimal.tsx +10 -0
- package/src/tests/fixtures/routes/with-comments.tsx +12 -0
- package/src/tests/fixtures/routes/with-nested-objects.tsx +15 -0
- package/src/tests/upstart-editor-api.test.ts +367 -0
- package/src/tests/vite-plugin-upstart-attrs.test.ts +1189 -0
- package/src/tests/vite-plugin-upstart-editor.test.ts +81 -0
- package/src/upstart-editor-api.ts +204 -0
- package/src/vite-plugin-upstart-attrs.ts +708 -0
- package/src/vite-plugin-upstart-editor/PLAN.md +1391 -0
- package/src/vite-plugin-upstart-editor/plugin.ts +73 -0
- package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +80 -0
- package/src/vite-plugin-upstart-editor/runtime/hover-overlay.ts +135 -0
- package/src/vite-plugin-upstart-editor/runtime/index.ts +90 -0
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +401 -0
- package/src/vite-plugin-upstart-editor/runtime/types.ts +120 -0
- package/src/vite-plugin-upstart-editor/runtime/utils.ts +34 -0
- package/src/vite-plugin-upstart-theme.ts +314 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { createUnplugin } from "unplugin";
|
|
2
|
+
import { parseSync } from "oxc-parser";
|
|
3
|
+
import { walk } from "zimmerframe";
|
|
4
|
+
import MagicString from "magic-string";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import type {
|
|
7
|
+
Program,
|
|
8
|
+
Node,
|
|
9
|
+
JSXElement,
|
|
10
|
+
JSXOpeningElement,
|
|
11
|
+
JSXAttribute,
|
|
12
|
+
Expression,
|
|
13
|
+
MemberExpression,
|
|
14
|
+
CallExpression,
|
|
15
|
+
} from "estree-jsx";
|
|
16
|
+
|
|
17
|
+
interface Options {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
emitRegistry?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type NodeWithRange = Node & { start: number; end: number };
|
|
23
|
+
|
|
24
|
+
function hasRange(node: any): node is NodeWithRange {
|
|
25
|
+
return node && typeof node.start === "number" && typeof node.end === "number";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fast, stable hash function (djb2 variant)
|
|
29
|
+
// Produces a short hex string that's stable across rebuilds
|
|
30
|
+
function hashContent(content: string): string {
|
|
31
|
+
let hash = 5381;
|
|
32
|
+
for (let i = 0; i < content.length; i++) {
|
|
33
|
+
hash = ((hash << 5) + hash) ^ content.charCodeAt(i);
|
|
34
|
+
}
|
|
35
|
+
// Convert to unsigned 32-bit and then to hex
|
|
36
|
+
return (hash >>> 0).toString(16);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface LoopContext {
|
|
40
|
+
itemName: string;
|
|
41
|
+
indexName: string | null;
|
|
42
|
+
arrayExpr: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
interface I18nKeyInfo {
|
|
47
|
+
/** The resolved key with namespace, e.g. "dashboard:features.title" */
|
|
48
|
+
fullKey: string;
|
|
49
|
+
/** Just the translation key, e.g. "features.title" */
|
|
50
|
+
key: string;
|
|
51
|
+
/** The namespace, e.g. "dashboard" */
|
|
52
|
+
namespace: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Registry entry for editable elements (text and className)
|
|
56
|
+
export interface EditableEntry {
|
|
57
|
+
file: string;
|
|
58
|
+
type: "text" | "className";
|
|
59
|
+
startOffset: number;
|
|
60
|
+
endOffset: number;
|
|
61
|
+
originalContent: string;
|
|
62
|
+
context: { parentTag: string };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Module-level registry (collected across all files during build)
|
|
66
|
+
const editableRegistry = new Map<string, EditableEntry>();
|
|
67
|
+
|
|
68
|
+
// Generate stable ID for editable elements
|
|
69
|
+
// Replace the counter-based ID generation with position-based
|
|
70
|
+
function generateId(filePath: string, node: NodeWithRange): string {
|
|
71
|
+
// Use file path + start position for a stable, deterministic ID
|
|
72
|
+
return `${filePath}:${node.start}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Export for testing - get a copy of the current registry
|
|
76
|
+
export function getRegistry(): Record<string, EditableEntry> {
|
|
77
|
+
return Object.fromEntries(editableRegistry);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Export for testing - clear registry and counters
|
|
81
|
+
export function clearRegistry(): void {
|
|
82
|
+
editableRegistry.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface TransformState {
|
|
86
|
+
filePath: string;
|
|
87
|
+
code: string;
|
|
88
|
+
s: MagicString;
|
|
89
|
+
loopStack: LoopContext[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const upstartEditor = createUnplugin<Options>((options) => {
|
|
93
|
+
if (!options.enabled) {
|
|
94
|
+
return { name: "upstart-editor-disabled" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const emitRegistry = options.emitRegistry ?? true;
|
|
98
|
+
let root = process.cwd();
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
name: "upstart-editor",
|
|
102
|
+
enforce: "pre",
|
|
103
|
+
|
|
104
|
+
vite: {
|
|
105
|
+
configResolved(config) {
|
|
106
|
+
root = config.root;
|
|
107
|
+
},
|
|
108
|
+
generateBundle() {
|
|
109
|
+
if (!emitRegistry || editableRegistry.size === 0) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const registry = {
|
|
114
|
+
version: 1,
|
|
115
|
+
generatedAt: new Date().toISOString(),
|
|
116
|
+
elements: Object.fromEntries(editableRegistry),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
this.emitFile({
|
|
120
|
+
type: "asset",
|
|
121
|
+
fileName: "upstart-registry.json",
|
|
122
|
+
source: JSON.stringify(registry, null, 2),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Clear for next build
|
|
126
|
+
editableRegistry.clear();
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
transformInclude(id) {
|
|
131
|
+
return /\.(tsx|jsx)$/.test(id) && !id.includes("node_modules");
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
transform(code, id) {
|
|
135
|
+
// Fast path: skip files without JSX
|
|
136
|
+
if (!code.includes("<")) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const relativePath = path.relative(root, id);
|
|
142
|
+
const result = transformWithOxc(code, relativePath);
|
|
143
|
+
|
|
144
|
+
if (!result) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
code: result.code,
|
|
150
|
+
map: result.map,
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error(`Error transforming ${id}:`, error);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export function transformWithOxc(code: string, filePath: string) {
|
|
161
|
+
// Parse with oxc (super fast!)
|
|
162
|
+
const ast = parseSync(filePath, code, {
|
|
163
|
+
sourceType: "module",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (!ast.program) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const s = new MagicString(code);
|
|
171
|
+
const state: TransformState = {
|
|
172
|
+
filePath,
|
|
173
|
+
code,
|
|
174
|
+
s,
|
|
175
|
+
loopStack: [],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
let modified = false;
|
|
179
|
+
|
|
180
|
+
// Use zimmerframe to walk and transform the AST
|
|
181
|
+
walk(ast.program as Program, state, {
|
|
182
|
+
_(node: Node, { state, next }: { state: TransformState; next: () => void }) {
|
|
183
|
+
// ENFORCE: Throw error if useTranslation is used for translations (destructuring `t`)
|
|
184
|
+
if (node.type === "VariableDeclaration") {
|
|
185
|
+
checkForbiddenUseTranslation(node, state.filePath);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Handle .map() calls to track loops (must be before JSXElement)
|
|
189
|
+
if (node.type === "CallExpression") {
|
|
190
|
+
const loopInfo = detectMapCall(node as CallExpression, state.code);
|
|
191
|
+
|
|
192
|
+
if (loopInfo) {
|
|
193
|
+
// Push loop context
|
|
194
|
+
state.loopStack.push(loopInfo);
|
|
195
|
+
|
|
196
|
+
// Continue walking into the callback
|
|
197
|
+
next();
|
|
198
|
+
|
|
199
|
+
// Pop loop context after traversing
|
|
200
|
+
state.loopStack.pop();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Handle JSX elements
|
|
206
|
+
if (node.type === "JSXElement") {
|
|
207
|
+
const jsxNode = node as JSXElement;
|
|
208
|
+
const opening = jsxNode.openingElement;
|
|
209
|
+
const tagName = getJSXElementName(opening);
|
|
210
|
+
const insertPos = getAttributeInsertPosition(opening, state.code);
|
|
211
|
+
|
|
212
|
+
// Track attributes to inject
|
|
213
|
+
const attributes: string[] = [];
|
|
214
|
+
|
|
215
|
+
// Compute stable hash from the element's source code (includes all children)
|
|
216
|
+
// This hash changes if ANY part of the element or its children change
|
|
217
|
+
if (hasRange(jsxNode)) {
|
|
218
|
+
const elementSource = state.code.slice(jsxNode.start, jsxNode.end);
|
|
219
|
+
const hash = hashContent(elementSource);
|
|
220
|
+
attributes.push(`data-upstart-hash="${hash}"`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check for text leaf elements (any element with only static text)
|
|
224
|
+
if (isTextLeafElement(jsxNode)) {
|
|
225
|
+
attributes.push('data-upstart-editable-text="true"');
|
|
226
|
+
|
|
227
|
+
// Find the actual JSXText node to track its location
|
|
228
|
+
const textChild = jsxNode.children.find((c) => c.type === "JSXText" && (c as any).value?.trim());
|
|
229
|
+
|
|
230
|
+
if (textChild && hasRange(textChild)) {
|
|
231
|
+
const id = generateId(state.filePath, textChild);
|
|
232
|
+
const textValue = (textChild as any).value as string;
|
|
233
|
+
|
|
234
|
+
// Calculate trimmed offsets (exclude leading/trailing whitespace)
|
|
235
|
+
const trimmedStart = textChild.start + (textValue.length - textValue.trimStart().length);
|
|
236
|
+
const trimmedEnd = textChild.end - (textValue.length - textValue.trimEnd().length);
|
|
237
|
+
|
|
238
|
+
editableRegistry.set(id, {
|
|
239
|
+
file: state.filePath,
|
|
240
|
+
type: "text",
|
|
241
|
+
startOffset: trimmedStart,
|
|
242
|
+
endOffset: trimmedEnd,
|
|
243
|
+
originalContent: textValue.trim(),
|
|
244
|
+
context: { parentTag: tagName || "unknown" },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
attributes.push(`data-upstart-id="${id}"`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Check if this element IS a <Trans> component - add i18n tracking attributes
|
|
252
|
+
const transInfo = detectTransComponent(jsxNode, state.code);
|
|
253
|
+
if (transInfo) {
|
|
254
|
+
attributes.push('data-upstart-editable-text="true"');
|
|
255
|
+
attributes.push(`data-i18n-key="${escapeProp(transInfo.fullKey)}"`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Track className attribute if it's a string literal
|
|
259
|
+
const classNameAttr = opening.attributes.find(
|
|
260
|
+
(attr): attr is JSXAttribute =>
|
|
261
|
+
attr.type === "JSXAttribute" &&
|
|
262
|
+
attr.name.type === "JSXIdentifier" &&
|
|
263
|
+
attr.name.name === "className" &&
|
|
264
|
+
attr.value?.type === "Literal" &&
|
|
265
|
+
typeof (attr.value as any).value === "string",
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
if (classNameAttr && classNameAttr.value && hasRange(classNameAttr.value)) {
|
|
269
|
+
const id = generateId(state.filePath, classNameAttr.value);
|
|
270
|
+
const classValue = (classNameAttr.value as any).value as string;
|
|
271
|
+
|
|
272
|
+
// +1 and -1 to exclude the quotes
|
|
273
|
+
editableRegistry.set(id, {
|
|
274
|
+
file: state.filePath,
|
|
275
|
+
type: "className",
|
|
276
|
+
startOffset: classNameAttr.value.start + 1,
|
|
277
|
+
endOffset: classNameAttr.value.end - 1,
|
|
278
|
+
originalContent: classValue,
|
|
279
|
+
context: { parentTag: tagName || "unknown" },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
attributes.push(`data-upstart-classname-id="${id}"`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Process PascalCase components for additional tracking
|
|
286
|
+
if (tagName && /^[A-Z]/.test(tagName)) {
|
|
287
|
+
// File and component tracking
|
|
288
|
+
attributes.push(`data-upstart-file="${escapeProp(state.filePath)}"`);
|
|
289
|
+
attributes.push(`data-upstart-component="${escapeProp(tagName)}"`);
|
|
290
|
+
|
|
291
|
+
// Loop context tracking
|
|
292
|
+
if (state.loopStack.length > 0) {
|
|
293
|
+
const loop = state.loopStack[state.loopStack.length - 1];
|
|
294
|
+
attributes.push(`data-upstart-loop-item="${escapeProp(loop.itemName)}"`);
|
|
295
|
+
if (loop.indexName) {
|
|
296
|
+
attributes.push(`data-upstart-loop-index={${loop.indexName.toLowerCase()}}`);
|
|
297
|
+
}
|
|
298
|
+
attributes.push(`data-upstart-loop-array="${escapeProp(loop.arrayExpr)}"`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Analyze each prop for data bindings
|
|
302
|
+
for (const attr of opening.attributes) {
|
|
303
|
+
if (attr.type !== "JSXAttribute" || attr.name.type !== "JSXIdentifier") {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const propName = attr.name.name;
|
|
308
|
+
const binding = analyzeBinding(attr.value, state.code);
|
|
309
|
+
|
|
310
|
+
if (binding) {
|
|
311
|
+
attributes.push(`data-upstart-prop-${propName.toLowerCase()}="${escapeProp(binding.path)}"`);
|
|
312
|
+
|
|
313
|
+
if (binding.datasource) {
|
|
314
|
+
attributes.push(
|
|
315
|
+
`data-upstart-datasource-${propName.toLowerCase()}="${escapeProp(binding.datasource)}"`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (binding.recordId) {
|
|
320
|
+
attributes.push(`data-upstart-record-id-${propName.toLowerCase()}={${binding.recordId}}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Track conditional expressions
|
|
324
|
+
if (binding.isConditional) {
|
|
325
|
+
attributes.push(`data-upstart-conditional-${propName.toLowerCase()}="true"`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Inject attributes if any
|
|
332
|
+
if (insertPos !== -1 && attributes.length > 0) {
|
|
333
|
+
const attrString = " " + attributes.join(" ");
|
|
334
|
+
state.s.appendLeft(insertPos, attrString);
|
|
335
|
+
modified = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
next();
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (!modified) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
code: s.toString(),
|
|
349
|
+
map: s.generateMap({ hires: true }),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Helper: Get JSX element name
|
|
354
|
+
function getJSXElementName(opening: JSXOpeningElement): string | null {
|
|
355
|
+
if (opening.name.type === "JSXIdentifier") {
|
|
356
|
+
return opening.name.name;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Handle JSXMemberExpression like <Foo.Bar>
|
|
360
|
+
if (opening.name.type === "JSXMemberExpression") {
|
|
361
|
+
let current = opening.name;
|
|
362
|
+
while (current.property) {
|
|
363
|
+
if (current.property.type === "JSXIdentifier") {
|
|
364
|
+
return current.property.name;
|
|
365
|
+
}
|
|
366
|
+
if (current.type === "JSXMemberExpression") {
|
|
367
|
+
current = current.property as any;
|
|
368
|
+
} else {
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Helper: Find where to insert attributes in JSX opening tag
|
|
378
|
+
function getAttributeInsertPosition(opening: JSXOpeningElement, code: string): number {
|
|
379
|
+
// If there are existing attributes, insert before the first one
|
|
380
|
+
if (opening.attributes.length > 0) {
|
|
381
|
+
const firstAttr = opening.attributes[0];
|
|
382
|
+
if (hasRange(firstAttr)) {
|
|
383
|
+
return firstAttr.start;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Otherwise, insert after the tag name
|
|
388
|
+
if (opening.name.type === "JSXIdentifier" && hasRange(opening.name)) {
|
|
389
|
+
return opening.name.end;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (opening.name.type === "JSXMemberExpression" && hasRange(opening.name)) {
|
|
393
|
+
return opening.name.end;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return -1;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Helper: Analyze a prop value to extract data binding
|
|
400
|
+
function analyzeBinding(
|
|
401
|
+
value: JSXAttribute["value"],
|
|
402
|
+
code: string,
|
|
403
|
+
): {
|
|
404
|
+
path: string;
|
|
405
|
+
datasource?: string;
|
|
406
|
+
recordId?: string;
|
|
407
|
+
isConditional?: boolean;
|
|
408
|
+
} | null {
|
|
409
|
+
if (!value) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (value.type === "JSXExpressionContainer") {
|
|
414
|
+
const expr = value.expression;
|
|
415
|
+
|
|
416
|
+
if (expr.type === "JSXEmptyExpression") {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return analyzeExpression(expr, code);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Helper: Analyze any expression to extract binding info
|
|
427
|
+
function analyzeExpression(
|
|
428
|
+
expr: Expression,
|
|
429
|
+
code: string,
|
|
430
|
+
): {
|
|
431
|
+
path: string;
|
|
432
|
+
datasource?: string;
|
|
433
|
+
recordId?: string;
|
|
434
|
+
isConditional?: boolean;
|
|
435
|
+
} | null {
|
|
436
|
+
// Handle member expressions: user.name, product.price, etc.
|
|
437
|
+
if (expr.type === "MemberExpression") {
|
|
438
|
+
const path = exprToString(expr, code);
|
|
439
|
+
const datasource = extractDatasource(expr);
|
|
440
|
+
const recordId = extractRecordId(expr);
|
|
441
|
+
|
|
442
|
+
return { path, datasource, recordId };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Handle identifiers: userName, price, etc.
|
|
446
|
+
if (expr.type === "Identifier") {
|
|
447
|
+
return { path: expr.name };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Handle conditional expressions: condition ? value1 : value2
|
|
451
|
+
if (expr.type === "ConditionalExpression") {
|
|
452
|
+
// Try to extract from the consequent
|
|
453
|
+
const consequent = analyzeExpression(expr.consequent, code);
|
|
454
|
+
if (consequent) {
|
|
455
|
+
return {
|
|
456
|
+
...consequent,
|
|
457
|
+
isConditional: true,
|
|
458
|
+
path: exprToString(expr, code),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Handle logical expressions: value1 && value2, value1 || value2
|
|
464
|
+
if (expr.type === "LogicalExpression") {
|
|
465
|
+
const right = analyzeExpression(expr.right, code);
|
|
466
|
+
if (right) {
|
|
467
|
+
return {
|
|
468
|
+
...right,
|
|
469
|
+
isConditional: true,
|
|
470
|
+
path: exprToString(expr, code),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// For other complex expressions, just return the path
|
|
476
|
+
const exprWithRange = expr as any;
|
|
477
|
+
if (exprWithRange.start !== undefined && exprWithRange.end !== undefined) {
|
|
478
|
+
return {
|
|
479
|
+
path: code.slice(exprWithRange.start, exprWithRange.end),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Helper: Convert expression AST to string
|
|
487
|
+
function exprToString(expr: Expression, code: string): string {
|
|
488
|
+
// Use the source range to get the actual code
|
|
489
|
+
const exprWithRange = expr as any;
|
|
490
|
+
if (exprWithRange.start !== undefined && exprWithRange.end !== undefined) {
|
|
491
|
+
return code.slice(exprWithRange.start, exprWithRange.end);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Fallback: reconstruct from AST
|
|
495
|
+
if (expr.type === "Identifier") {
|
|
496
|
+
return expr.name;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (expr.type === "MemberExpression") {
|
|
500
|
+
const obj = exprToString(expr.object as Expression, code);
|
|
501
|
+
const prop =
|
|
502
|
+
expr.property.type === "Identifier" && !expr.computed
|
|
503
|
+
? expr.property.name
|
|
504
|
+
: exprToString(expr.property as Expression, code);
|
|
505
|
+
return expr.computed ? `${obj}[${prop}]` : `${obj}.${prop}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (expr.type === "ConditionalExpression") {
|
|
509
|
+
const test = exprToString(expr.test, code);
|
|
510
|
+
const consequent = exprToString(expr.consequent, code);
|
|
511
|
+
const alternate = exprToString(expr.alternate, code);
|
|
512
|
+
return `${test} ? ${consequent} : ${alternate}`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (expr.type === "LogicalExpression") {
|
|
516
|
+
const left = exprToString(expr.left, code);
|
|
517
|
+
const right = exprToString(expr.right, code);
|
|
518
|
+
return `${left} ${expr.operator} ${right}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return "";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Helper: Extract datasource name from member expression
|
|
525
|
+
function extractDatasource(expr: MemberExpression): string | undefined {
|
|
526
|
+
let current: Expression = expr.object as Expression;
|
|
527
|
+
|
|
528
|
+
// Traverse to the root object
|
|
529
|
+
while (current.type === "MemberExpression") {
|
|
530
|
+
current = current.object as Expression;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (current.type === "Identifier") {
|
|
534
|
+
return current.name;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Helper: Extract record ID reference
|
|
541
|
+
function extractRecordId(expr: MemberExpression): string | undefined {
|
|
542
|
+
const obj = expr.object;
|
|
543
|
+
|
|
544
|
+
if (obj.type === "Identifier") {
|
|
545
|
+
// Assume the ID field is `${object}.id`
|
|
546
|
+
return `${obj.name}.id`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (obj.type === "MemberExpression") {
|
|
550
|
+
// For nested objects like user.profile.id
|
|
551
|
+
const datasource = extractDatasource(obj);
|
|
552
|
+
if (datasource) {
|
|
553
|
+
return `${datasource}.id`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Helper: Detect .map() calls and extract loop context
|
|
561
|
+
function detectMapCall(node: CallExpression, code: string): LoopContext | null {
|
|
562
|
+
// Check if this is a .map() call
|
|
563
|
+
if (
|
|
564
|
+
node.callee.type !== "MemberExpression" ||
|
|
565
|
+
node.callee.property.type !== "Identifier" ||
|
|
566
|
+
node.callee.property.name !== "map"
|
|
567
|
+
) {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const callback = node.arguments[0];
|
|
572
|
+
|
|
573
|
+
if (!callback) {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Check if callback is an arrow function or function expression
|
|
578
|
+
if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression") {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const params = callback.params;
|
|
583
|
+
const itemParam = params[0];
|
|
584
|
+
const indexParam = params[1];
|
|
585
|
+
|
|
586
|
+
if (!itemParam) {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Extract parameter names
|
|
591
|
+
const itemName = itemParam.type === "Identifier" ? itemParam.name : "item";
|
|
592
|
+
const indexName = indexParam?.type === "Identifier" ? indexParam.name : null;
|
|
593
|
+
|
|
594
|
+
// Get the array expression
|
|
595
|
+
const arrayExpr = exprToString(node.callee.object as Expression, code);
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
itemName,
|
|
599
|
+
indexName,
|
|
600
|
+
arrayExpr,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Helper: Escape prop values for JSX attributes
|
|
605
|
+
function escapeProp(value: string): string {
|
|
606
|
+
return value
|
|
607
|
+
.replace(/&/g, "&")
|
|
608
|
+
.replace(/"/g, """)
|
|
609
|
+
.replace(/'/g, "'")
|
|
610
|
+
.replace(/</g, "<")
|
|
611
|
+
.replace(/>/g, ">");
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Helper: ENFORCE forbidden useTranslation usage - throw error if `t` is destructured
|
|
615
|
+
// Only `{ i18n }` is allowed (for language switching), not `{ t }` (use <Trans> instead)
|
|
616
|
+
function checkForbiddenUseTranslation(node: Node, filePath: string): void {
|
|
617
|
+
if (node.type !== "VariableDeclaration") return;
|
|
618
|
+
|
|
619
|
+
const decl = node as any;
|
|
620
|
+
for (const declarator of decl.declarations) {
|
|
621
|
+
if (
|
|
622
|
+
declarator.id?.type !== "ObjectPattern" ||
|
|
623
|
+
!declarator.init ||
|
|
624
|
+
declarator.init.type !== "CallExpression"
|
|
625
|
+
) {
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const callExpr = declarator.init;
|
|
630
|
+
if (callExpr.callee?.type !== "Identifier" || callExpr.callee.name !== "useTranslation") {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Check if `t` is being destructured (forbidden)
|
|
635
|
+
for (const prop of declarator.id.properties) {
|
|
636
|
+
if (prop.type !== "Property") continue;
|
|
637
|
+
|
|
638
|
+
if (prop.key?.type === "Identifier" && prop.key.name === "t") {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`[${filePath}] useTranslation hook is forbidden for translations. ` +
|
|
641
|
+
`Use <Trans i18nKey="..." /> component instead. ` +
|
|
642
|
+
`Only { i18n } destructuring is allowed (for language switching).`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Helper: Detect <Trans i18nKey="..." /> component and extract i18n key info
|
|
650
|
+
function detectTransComponent(jsxElement: JSXElement, code: string): I18nKeyInfo | null {
|
|
651
|
+
const opening = jsxElement.openingElement;
|
|
652
|
+
const tagName = getJSXElementName(opening);
|
|
653
|
+
|
|
654
|
+
if (tagName !== "Trans") return null;
|
|
655
|
+
|
|
656
|
+
let i18nKey: string | null = null;
|
|
657
|
+
let namespace = "translation";
|
|
658
|
+
|
|
659
|
+
for (const attr of opening.attributes) {
|
|
660
|
+
if (attr.type !== "JSXAttribute" || attr.name.type !== "JSXIdentifier") continue;
|
|
661
|
+
|
|
662
|
+
const attrName = attr.name.name;
|
|
663
|
+
|
|
664
|
+
// Extract i18nKey prop value
|
|
665
|
+
if (attrName === "i18nKey" && attr.value?.type === "Literal") {
|
|
666
|
+
i18nKey = (attr.value as any).value as string;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Extract ns prop value (optional namespace override)
|
|
670
|
+
if (attrName === "ns" && attr.value?.type === "Literal") {
|
|
671
|
+
namespace = (attr.value as any).value as string;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (!i18nKey) return null;
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
fullKey: `${namespace}:${i18nKey}`,
|
|
679
|
+
key: i18nKey,
|
|
680
|
+
namespace,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Helper: Check if element is a "leaf" with only static text (no nested elements or expressions)
|
|
685
|
+
function isTextLeafElement(jsxElement: JSXElement): boolean {
|
|
686
|
+
let hasText = false;
|
|
687
|
+
|
|
688
|
+
for (const child of jsxElement.children) {
|
|
689
|
+
if (child.type === "JSXText") {
|
|
690
|
+
const textValue = (child as any).value;
|
|
691
|
+
if (textValue?.trim()) {
|
|
692
|
+
hasText = true;
|
|
693
|
+
}
|
|
694
|
+
} else if (
|
|
695
|
+
child.type === "JSXElement" ||
|
|
696
|
+
child.type === "JSXFragment" ||
|
|
697
|
+
child.type === "JSXExpressionContainer" ||
|
|
698
|
+
child.type === "JSXSpreadChild"
|
|
699
|
+
) {
|
|
700
|
+
// Has non-text children, not a leaf element
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return hasText;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export default upstartEditor.vite;
|