@upstart.gg/vite-plugins 0.1.29 → 0.1.31
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/upstart-editor-api.d.ts +13 -1
- package/dist/upstart-editor-api.d.ts.map +1 -1
- package/dist/upstart-editor-api.js +59 -1
- package/dist/upstart-editor-api.js.map +1 -1
- package/dist/vite-plugin-upstart-attrs.d.ts +12 -7
- package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-attrs.js +195 -3
- package/dist/vite-plugin-upstart-attrs.js.map +1 -1
- package/dist/vite-plugin-upstart-branding/plugin.d.ts +3 -3
- package/dist/vite-plugin-upstart-branding/plugin.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-branding/plugin.js.map +1 -1
- package/dist/vite-plugin-upstart-branding/runtime.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/plugin.d.ts +3 -3
- package/dist/vite-plugin-upstart-editor/plugin.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/plugin.js +4 -1
- package/dist/vite-plugin-upstart-editor/plugin.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +27 -16
- package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/error-handler.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.d.ts +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.js +14 -2
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
- package/dist/{src/vite-plugin-upstart-editor → vite-plugin-upstart-editor}/runtime/state.d.ts +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/state.d.ts.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/state.js.map +1 -0
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +212 -28
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +16 -3
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/utils.js.map +1 -1
- package/dist/vite-plugin-upstart-theme.d.ts +3 -3
- package/dist/vite-plugin-upstart-theme.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-theme.js +2 -2
- package/dist/vite-plugin-upstart-theme.js.map +1 -1
- package/package.json +7 -7
- package/src/tests/vite-plugin-upstart-attrs.test.ts +298 -37
- package/src/upstart-editor-api.ts +71 -0
- package/src/vite-plugin-upstart-attrs.ts +293 -5
- package/src/vite-plugin-upstart-editor/plugin.ts +11 -1
- package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +35 -21
- package/src/vite-plugin-upstart-editor/runtime/index.ts +21 -1
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +260 -41
- package/src/vite-plugin-upstart-editor/runtime/types.ts +17 -4
- package/src/vite-plugin-upstart-theme.ts +4 -1
- package/dist/src/vite-plugin-upstart-editor/runtime/state.d.ts.map +0 -1
- package/dist/src/vite-plugin-upstart-editor/runtime/state.js.map +0 -1
- /package/dist/{src/vite-plugin-upstart-editor → vite-plugin-upstart-editor}/runtime/state.js +0 -0
|
@@ -3,6 +3,7 @@ import { parseSync } from "oxc-parser";
|
|
|
3
3
|
import { walk } from "zimmerframe";
|
|
4
4
|
import MagicString from "magic-string";
|
|
5
5
|
import path from "path";
|
|
6
|
+
import fs from "node:fs";
|
|
6
7
|
import type {
|
|
7
8
|
Program,
|
|
8
9
|
Node,
|
|
@@ -52,15 +53,27 @@ interface I18nKeyInfo {
|
|
|
52
53
|
keyExpr: string | null;
|
|
53
54
|
/** Source expression for namespace when dynamic, null when static */
|
|
54
55
|
nsExpr: string | null;
|
|
56
|
+
/** Variable names from the `values` prop (e.g. ["year"] from values={{ year }}) */
|
|
57
|
+
valueKeys?: string[];
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
//
|
|
60
|
+
// A segment of a mixed-text element: either static text or a JSX expression
|
|
61
|
+
export interface EditableSegment {
|
|
62
|
+
type: "text" | "expr";
|
|
63
|
+
// For "text": the raw JSX text value (with whitespace/newlines as in source)
|
|
64
|
+
// For "expr": the full expression source including braces, e.g. "{new Date().getFullYear()}"
|
|
65
|
+
raw: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Registry entry for editable elements (text, rich-text, className, mixed-text)
|
|
58
69
|
export interface EditableEntry {
|
|
59
70
|
file: string;
|
|
60
|
-
type: "text" | "className";
|
|
71
|
+
type: "text" | "rich-text" | "className" | "mixed-text";
|
|
61
72
|
startOffset: number;
|
|
62
73
|
endOffset: number;
|
|
63
74
|
originalContent: string;
|
|
75
|
+
// Only present for mixed-text entries
|
|
76
|
+
segments?: EditableSegment[];
|
|
64
77
|
context: { parentTag: string };
|
|
65
78
|
}
|
|
66
79
|
|
|
@@ -102,6 +115,27 @@ export const upstartEditor = createUnplugin<Options>((options) => {
|
|
|
102
115
|
|
|
103
116
|
const emitRegistry = options.emitRegistry ?? true;
|
|
104
117
|
let root = process.cwd();
|
|
118
|
+
let isDevMode = false;
|
|
119
|
+
let devWriteTimer: ReturnType<typeof setTimeout> | null = null;
|
|
120
|
+
|
|
121
|
+
const flushDevRegistry = () => {
|
|
122
|
+
if (!isDevMode || editableRegistry.size === 0) return;
|
|
123
|
+
const registryDir = path.join(root, "build", "server");
|
|
124
|
+
const registryPath = path.join(registryDir, "upstart-registry.json");
|
|
125
|
+
const registry = {
|
|
126
|
+
version: 1,
|
|
127
|
+
generatedAt: new Date().toISOString(),
|
|
128
|
+
elements: Object.fromEntries(editableRegistry),
|
|
129
|
+
};
|
|
130
|
+
fs.mkdirSync(registryDir, { recursive: true });
|
|
131
|
+
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const scheduleDevRegistryWrite = () => {
|
|
135
|
+
if (!isDevMode) return;
|
|
136
|
+
if (devWriteTimer) clearTimeout(devWriteTimer);
|
|
137
|
+
devWriteTimer = setTimeout(flushDevRegistry, 150);
|
|
138
|
+
};
|
|
105
139
|
|
|
106
140
|
return {
|
|
107
141
|
name: "upstart-editor",
|
|
@@ -110,6 +144,10 @@ export const upstartEditor = createUnplugin<Options>((options) => {
|
|
|
110
144
|
vite: {
|
|
111
145
|
configResolved(config) {
|
|
112
146
|
root = config.root;
|
|
147
|
+
isDevMode = config.command === "serve";
|
|
148
|
+
},
|
|
149
|
+
configureServer(_server) {
|
|
150
|
+
isDevMode = true;
|
|
113
151
|
},
|
|
114
152
|
generateBundle() {
|
|
115
153
|
if (!emitRegistry || editableRegistry.size === 0) {
|
|
@@ -151,6 +189,7 @@ export const upstartEditor = createUnplugin<Options>((options) => {
|
|
|
151
189
|
return null;
|
|
152
190
|
}
|
|
153
191
|
|
|
192
|
+
scheduleDevRegistryWrite();
|
|
154
193
|
return {
|
|
155
194
|
code: result.code,
|
|
156
195
|
map: result.map,
|
|
@@ -270,7 +309,7 @@ export function transformWithOxc(code: string, filePath: string) {
|
|
|
270
309
|
const allI18nKeys = [...transChildren, ...tCallChildren];
|
|
271
310
|
const hasI18n = allI18nKeys.length > 0;
|
|
272
311
|
|
|
273
|
-
if (hasI18n) {
|
|
312
|
+
if (hasI18n && !hasMixedNonI18nContent(jsxNode, state.code, state, state.constants)) {
|
|
274
313
|
attributes.push('data-upstart-editable-text="true"');
|
|
275
314
|
attributes.push('data-upstart-editable-text-mode="plain"');
|
|
276
315
|
|
|
@@ -287,12 +326,18 @@ export function transformWithOxc(code: string, filePath: string) {
|
|
|
287
326
|
const keys = allI18nKeys.map((t: I18nKeyInfo) => escapeProp(t.fullKey)).join(",");
|
|
288
327
|
attributes.push(`data-upstart-i18n="${keys}"`);
|
|
289
328
|
}
|
|
329
|
+
|
|
330
|
+
const allValueKeys = [...new Set(allI18nKeys.flatMap((t) => t.valueKeys ?? []))];
|
|
331
|
+
if (allValueKeys.length > 0) {
|
|
332
|
+
attributes.push(`data-i18n-values="${allValueKeys.join(",")}"`);
|
|
333
|
+
}
|
|
290
334
|
}
|
|
291
335
|
|
|
292
|
-
// Step
|
|
336
|
+
// Step 2a: Text leaf elements — plain text, editable directly in the TSX source
|
|
293
337
|
if (isTextLeafElement(jsxNode)) {
|
|
294
338
|
if (!hasI18n) {
|
|
295
|
-
attributes.push('data-upstart-editable-text="
|
|
339
|
+
attributes.push('data-upstart-editable-text="true"');
|
|
340
|
+
attributes.push('data-upstart-editable-text-mode="direct"');
|
|
296
341
|
}
|
|
297
342
|
|
|
298
343
|
// Find the actual JSXText node to track its location
|
|
@@ -318,6 +363,78 @@ export function transformWithOxc(code: string, filePath: string) {
|
|
|
318
363
|
attributes.push(`data-upstart-id="${id}"`);
|
|
319
364
|
}
|
|
320
365
|
}
|
|
366
|
+
// Step 2b: Almost-leaf elements — inline formatting tags only, editable via InspectorPanel
|
|
367
|
+
else if (!hasI18n && isAlmostLeafElement(jsxNode)) {
|
|
368
|
+
attributes.push('data-upstart-editable-text="true"');
|
|
369
|
+
attributes.push('data-upstart-editable-text-mode="rich-panel"');
|
|
370
|
+
|
|
371
|
+
// Track the full children range so the server can overwrite them in the TSX source
|
|
372
|
+
const children = jsxNode.children.filter((c) => hasRange(c));
|
|
373
|
+
const firstChild = children[0];
|
|
374
|
+
const lastChild = children[children.length - 1];
|
|
375
|
+
|
|
376
|
+
if (firstChild && lastChild && hasRange(firstChild) && hasRange(lastChild)) {
|
|
377
|
+
const id = generateId(state.filePath, firstChild);
|
|
378
|
+
const originalContent = state.code.slice(firstChild.start, lastChild.end);
|
|
379
|
+
|
|
380
|
+
editableRegistry.set(id, {
|
|
381
|
+
file: state.filePath,
|
|
382
|
+
type: "rich-text",
|
|
383
|
+
startOffset: firstChild.start,
|
|
384
|
+
endOffset: lastChild.end,
|
|
385
|
+
originalContent,
|
|
386
|
+
context: { parentTag: tagName || "unknown" },
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
attributes.push(`data-upstart-id="${id}"`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// Step 2c: Mixed-text leaf — JSXText + JSXExpressionContainer children only.
|
|
393
|
+
// Expressions become non-editable chips; surrounding text is editable.
|
|
394
|
+
else if (!hasI18n && isMixedTextLeafElement(jsxNode)) {
|
|
395
|
+
attributes.push('data-upstart-editable-text="true"');
|
|
396
|
+
attributes.push('data-upstart-editable-text-mode="plain"');
|
|
397
|
+
|
|
398
|
+
const rangedChildren = jsxNode.children.filter((c) => hasRange(c));
|
|
399
|
+
const firstChild = rangedChildren[0];
|
|
400
|
+
const lastChild = rangedChildren[rangedChildren.length - 1];
|
|
401
|
+
|
|
402
|
+
if (firstChild && lastChild && hasRange(firstChild) && hasRange(lastChild)) {
|
|
403
|
+
const segments: EditableSegment[] = [];
|
|
404
|
+
let exprIndex = 0;
|
|
405
|
+
let templateStr = "";
|
|
406
|
+
|
|
407
|
+
for (const child of jsxNode.children) {
|
|
408
|
+
if (child.type === "JSXText") {
|
|
409
|
+
const normalized = normalizeJSXText((child as any).value as string);
|
|
410
|
+
segments.push({ type: "text", raw: (child as any).value as string });
|
|
411
|
+
templateStr += normalized;
|
|
412
|
+
} else if (child.type === "JSXExpressionContainer") {
|
|
413
|
+
const expr = (child as any).expression;
|
|
414
|
+
if (!expr || expr.type === "JSXEmptyExpression") continue;
|
|
415
|
+
const raw = hasRange(child) ? state.code.slice(child.start, child.end) : "";
|
|
416
|
+
segments.push({ type: "expr", raw });
|
|
417
|
+
templateStr += `{{${exprIndex++}}}`;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const id = generateId(state.filePath, firstChild);
|
|
422
|
+
const originalContent = state.code.slice(firstChild.start, lastChild.end);
|
|
423
|
+
|
|
424
|
+
editableRegistry.set(id, {
|
|
425
|
+
file: state.filePath,
|
|
426
|
+
type: "mixed-text",
|
|
427
|
+
startOffset: firstChild.start,
|
|
428
|
+
endOffset: lastChild.end,
|
|
429
|
+
originalContent,
|
|
430
|
+
segments,
|
|
431
|
+
context: { parentTag: tagName || "unknown" },
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
attributes.push(`data-upstart-id="${id}"`);
|
|
435
|
+
attributes.push(`data-upstart-mixed-template="${escapeProp(templateStr.trim())}"`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
321
438
|
// Step 3: Non-leaf elements with visible text content (expressions, mixed content)
|
|
322
439
|
else if (!hasI18n && hasVisibleTextContent(jsxNode)) {
|
|
323
440
|
attributes.push('data-upstart-editable-text="false"');
|
|
@@ -758,6 +875,7 @@ function detectTransComponent(
|
|
|
758
875
|
|
|
759
876
|
let keyResult: { value: string; expr: string | null } | null = null;
|
|
760
877
|
let nsResult: { value: string; expr: string | null } | null = null;
|
|
878
|
+
let valueKeys: string[] | undefined;
|
|
761
879
|
|
|
762
880
|
for (const attr of opening.attributes) {
|
|
763
881
|
if (attr.type !== "JSXAttribute" || attr.name.type !== "JSXIdentifier") continue;
|
|
@@ -771,6 +889,25 @@ function detectTransComponent(
|
|
|
771
889
|
if (attrName === "ns") {
|
|
772
890
|
nsResult = resolveJSXAttrValue(attr.value, code, constants);
|
|
773
891
|
}
|
|
892
|
+
|
|
893
|
+
if (attrName === "values") {
|
|
894
|
+
const container = attr.value as any;
|
|
895
|
+
if (container?.type === "JSXExpressionContainer") {
|
|
896
|
+
const expr = container.expression;
|
|
897
|
+
if (expr?.type === "ObjectExpression") {
|
|
898
|
+
const keys: string[] = [];
|
|
899
|
+
for (const prop of expr.properties) {
|
|
900
|
+
// oxc-parser uses estree "Property"; Babel uses "ObjectProperty"
|
|
901
|
+
if ((prop.type === "Property" || prop.type === "ObjectProperty") && prop.key) {
|
|
902
|
+
if (prop.key.type === "Identifier") keys.push(prop.key.name);
|
|
903
|
+
else if (prop.key.type === "Literal" || prop.key.type === "StringLiteral")
|
|
904
|
+
keys.push(prop.key.value);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (keys.length > 0) valueKeys = keys;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
774
911
|
}
|
|
775
912
|
|
|
776
913
|
if (!keyResult) {
|
|
@@ -788,6 +925,7 @@ function detectTransComponent(
|
|
|
788
925
|
namespace,
|
|
789
926
|
keyExpr,
|
|
790
927
|
nsExpr,
|
|
928
|
+
valueKeys,
|
|
791
929
|
};
|
|
792
930
|
}
|
|
793
931
|
|
|
@@ -889,6 +1027,54 @@ function findTCallsInChildren(jsxElement: JSXElement, code: string, state: Trans
|
|
|
889
1027
|
return results;
|
|
890
1028
|
}
|
|
891
1029
|
|
|
1030
|
+
// Returns true if the element mixes i18n content with non-i18n text/expressions.
|
|
1031
|
+
// In that case, inline editing would incorrectly overwrite the translation with
|
|
1032
|
+
// the full rendered text (including static parts like "© 2026 Company.").
|
|
1033
|
+
//
|
|
1034
|
+
// Safe patterns (NOT mixed):
|
|
1035
|
+
// {showWelcome && <Trans>} — boolean guard around Trans
|
|
1036
|
+
// {cond ? <Trans a> : <Trans b>} — ternary between two Trans
|
|
1037
|
+
// {" "} — whitespace-only literal
|
|
1038
|
+
//
|
|
1039
|
+
// Mixed patterns (flagged):
|
|
1040
|
+
// "© John Doe." + <Trans> — non-whitespace JSXText alongside i18n
|
|
1041
|
+
// {year} + <Trans> — text-producing Identifier/MemberExpression alongside i18n
|
|
1042
|
+
function hasMixedNonI18nContent(
|
|
1043
|
+
jsxNode: JSXElement,
|
|
1044
|
+
code: string,
|
|
1045
|
+
state: TransformState,
|
|
1046
|
+
constants: Map<string, string>,
|
|
1047
|
+
): boolean {
|
|
1048
|
+
for (const child of jsxNode.children) {
|
|
1049
|
+
if (child.type === "JSXText") {
|
|
1050
|
+
if (((child as any).value as string).trim().length > 0) return true;
|
|
1051
|
+
} else if (child.type === "JSXExpressionContainer") {
|
|
1052
|
+
const expr = (child as any).expression;
|
|
1053
|
+
if (!expr || expr.type === "JSXEmptyExpression") continue;
|
|
1054
|
+
// Whitespace-only string literal (e.g. {" "}) is harmless
|
|
1055
|
+
if (expr.type === "Literal" && typeof expr.value === "string" && expr.value.trim() === "") continue;
|
|
1056
|
+
// If expression contains i18n content (t() or Trans), it is not non-i18n text
|
|
1057
|
+
const tResults: I18nKeyInfo[] = [];
|
|
1058
|
+
findTCallInExpression(expr, code, tResults, state);
|
|
1059
|
+
if (tResults.length > 0) continue;
|
|
1060
|
+
const transResults: I18nKeyInfo[] = [];
|
|
1061
|
+
findTransInExpression(expr, code, transResults, constants);
|
|
1062
|
+
if (transResults.length > 0) continue;
|
|
1063
|
+
// Flag only expressions that produce visible text (same heuristic as hasVisibleTextContent)
|
|
1064
|
+
const type = expr.type;
|
|
1065
|
+
if (
|
|
1066
|
+
type === "Identifier" ||
|
|
1067
|
+
type === "MemberExpression" ||
|
|
1068
|
+
type === "Literal" ||
|
|
1069
|
+
type === "TemplateLiteral"
|
|
1070
|
+
) {
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
892
1078
|
// Helper: Recursively search an expression tree for t() calls.
|
|
893
1079
|
// Handles LogicalExpression (&&, ||), ConditionalExpression (?:), CallExpression (.map()),
|
|
894
1080
|
// ArrowFunctionExpression/FunctionExpression (callbacks), BlockStatement, and direct CallExpression.
|
|
@@ -991,6 +1177,108 @@ function isTextLeafElement(jsxElement: JSXElement): boolean {
|
|
|
991
1177
|
return hasText;
|
|
992
1178
|
}
|
|
993
1179
|
|
|
1180
|
+
// Inline HTML tags that purely style text without introducing block structure.
|
|
1181
|
+
// An element whose children are only JSXText + these tags is an "almost-leaf"
|
|
1182
|
+
// that can be edited as rich text via the InspectorPanel.
|
|
1183
|
+
const INLINE_FORMATTING_TAGS = new Set([
|
|
1184
|
+
"b",
|
|
1185
|
+
"i",
|
|
1186
|
+
"em",
|
|
1187
|
+
"strong",
|
|
1188
|
+
"u",
|
|
1189
|
+
"s",
|
|
1190
|
+
"del",
|
|
1191
|
+
"ins",
|
|
1192
|
+
"mark",
|
|
1193
|
+
"small",
|
|
1194
|
+
"sub",
|
|
1195
|
+
"sup",
|
|
1196
|
+
"code",
|
|
1197
|
+
"kbd",
|
|
1198
|
+
"abbr",
|
|
1199
|
+
"cite",
|
|
1200
|
+
"time",
|
|
1201
|
+
"span",
|
|
1202
|
+
"a",
|
|
1203
|
+
]);
|
|
1204
|
+
|
|
1205
|
+
// Returns true if the element's children are exclusively JSXText nodes and
|
|
1206
|
+
// known inline formatting tags (no expressions, no block elements, no fragments).
|
|
1207
|
+
// Must have at least one inline tag (otherwise it would be a plain text leaf).
|
|
1208
|
+
// The inline child tags must themselves be "simple" — only JSXText children,
|
|
1209
|
+
// no expressions, no attributes — so that <span className="...">{expr}</span>
|
|
1210
|
+
// siblings are not mistaken for plain inline formatting wrappers.
|
|
1211
|
+
function isAlmostLeafElement(jsxElement: JSXElement): boolean {
|
|
1212
|
+
let hasInlineElement = false;
|
|
1213
|
+
|
|
1214
|
+
for (const child of jsxElement.children) {
|
|
1215
|
+
if (child.type === "JSXText") {
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
if (child.type === "JSXElement") {
|
|
1219
|
+
const childEl = child as JSXElement;
|
|
1220
|
+
const tagName = getJSXElementName(childEl.openingElement);
|
|
1221
|
+
if (!tagName || !INLINE_FORMATTING_TAGS.has(tagName.toLowerCase())) return false;
|
|
1222
|
+
// The inline child must be truly simple: no attributes and only JSXText children.
|
|
1223
|
+
// A <span className="..."> or <span>{expr}</span> is not a simple inline wrapper.
|
|
1224
|
+
if (childEl.openingElement.attributes.length > 0) return false;
|
|
1225
|
+
for (const grandchild of childEl.children) {
|
|
1226
|
+
if (grandchild.type !== "JSXText") return false;
|
|
1227
|
+
}
|
|
1228
|
+
hasInlineElement = true;
|
|
1229
|
+
} else {
|
|
1230
|
+
return false;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
return hasInlineElement;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Normalise a raw JSX text node value the same way React/Babel does at compile time:
|
|
1238
|
+
// - Split by newlines
|
|
1239
|
+
// - For non-first lines, strip leading whitespace; for non-last lines, strip trailing whitespace
|
|
1240
|
+
// - Remove lines that are entirely whitespace
|
|
1241
|
+
// - Join surviving lines, appending a space after each non-last non-empty line
|
|
1242
|
+
function normalizeJSXText(raw: string): string {
|
|
1243
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
1244
|
+
const lastNonEmptyIdx = lines.reduce((acc, line, i) => (/[^ \t]/.test(line) ? i : acc), -1);
|
|
1245
|
+
let result = "";
|
|
1246
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1247
|
+
let line = lines[i].replace(/\t/g, " ");
|
|
1248
|
+
if (i > 0) line = line.replace(/^[ ]+/, "");
|
|
1249
|
+
if (i < lines.length - 1) line = line.replace(/[ ]+$/, "");
|
|
1250
|
+
if (!line) continue;
|
|
1251
|
+
if (i !== lastNonEmptyIdx) line += " ";
|
|
1252
|
+
result += line;
|
|
1253
|
+
}
|
|
1254
|
+
return result;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Returns true if the element's children are exclusively JSXText + JSXExpressionContainer nodes
|
|
1258
|
+
// (no nested elements), AND it has at least one of each — making the static text parts editable
|
|
1259
|
+
// while expressions are rendered as non-editable chips.
|
|
1260
|
+
function isMixedTextLeafElement(jsxElement: JSXElement): boolean {
|
|
1261
|
+
let hasText = false;
|
|
1262
|
+
let hasExpr = false;
|
|
1263
|
+
|
|
1264
|
+
for (const child of jsxElement.children) {
|
|
1265
|
+
if (child.type === "JSXText") {
|
|
1266
|
+
if (normalizeJSXText((child as any).value as string).length > 0) hasText = true;
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
if (child.type === "JSXExpressionContainer") {
|
|
1270
|
+
const expr = (child as any).expression;
|
|
1271
|
+
if (!expr || expr.type === "JSXEmptyExpression") continue;
|
|
1272
|
+
hasExpr = true;
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
// JSXElement, JSXFragment, JSXSpreadChild → not a pure mixed-text leaf
|
|
1276
|
+
return false;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
return hasText && hasExpr;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
994
1282
|
// Helper: Check if element has any visible text content (static or dynamic).
|
|
995
1283
|
// Broader than isTextLeafElement — returns true even if the element has other child types.
|
|
996
1284
|
// Detects JSXText with non-whitespace content and expression containers with
|
|
@@ -47,8 +47,18 @@ export const upstartEditor = createUnplugin<UpstartEditorPluginOptions>((options
|
|
|
47
47
|
const imports = `import { initUpstartEditor, waitForHydration } from ${JSON.stringify(runtimePath)};`;
|
|
48
48
|
const injection = "waitForHydration(initUpstartEditor);";
|
|
49
49
|
|
|
50
|
+
// If this entry file imports i18next, monkey-patch its init() so the
|
|
51
|
+
// resolved instance is exposed on window.__i18next for the editor runtime.
|
|
52
|
+
const i18nextImportMatch = code.match(/import\s+(\w+)\s+from\s+["']i18next["']/);
|
|
53
|
+
const i18nextVarName = i18nextImportMatch?.[1];
|
|
54
|
+
const i18nextPatch = i18nextVarName
|
|
55
|
+
? `;(function(){var _i=` +
|
|
56
|
+
i18nextVarName +
|
|
57
|
+
`,_o=_i.init.bind(_i);_i.init=function(){return Promise.resolve(_o.apply(_i,arguments)).then(function(r){if(typeof window!=="undefined")window.__i18next=_i;return r;});};})();`
|
|
58
|
+
: "";
|
|
59
|
+
|
|
50
60
|
return {
|
|
51
|
-
code: `${imports}${code}${injection}`,
|
|
61
|
+
code: `${imports}${i18nextPatch}${code}${injection}`,
|
|
52
62
|
map: null,
|
|
53
63
|
};
|
|
54
64
|
},
|
|
@@ -81,10 +81,15 @@ function handleClick(event: MouseEvent): void {
|
|
|
81
81
|
if (target.closest("[contenteditable='true']")) {
|
|
82
82
|
const editableEl = target.closest<HTMLElement>("[data-upstart-editable-text='true']");
|
|
83
83
|
if (editableEl) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
"";
|
|
84
|
+
let classNameId = editableEl.dataset.upstartClassnameId ?? "";
|
|
85
|
+
let currentClassName = editableEl.className;
|
|
86
|
+
if (!classNameId) {
|
|
87
|
+
const parentWithClassname = editableEl.closest<HTMLElement>("[data-upstart-classname-id]");
|
|
88
|
+
if (parentWithClassname) {
|
|
89
|
+
classNameId = parentWithClassname.dataset.upstartClassnameId ?? "";
|
|
90
|
+
currentClassName = parentWithClassname.className;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
88
93
|
if (classNameId) {
|
|
89
94
|
const computedStyle = getComputedStyle(document.documentElement);
|
|
90
95
|
const themeColors: Record<string, string> = {};
|
|
@@ -98,7 +103,7 @@ function handleClick(event: MouseEvent): void {
|
|
|
98
103
|
componentName: editableEl.dataset.upstartComponent,
|
|
99
104
|
filePath: editableEl.dataset.upstartFile ?? "",
|
|
100
105
|
classNameId,
|
|
101
|
-
currentClassName
|
|
106
|
+
currentClassName,
|
|
102
107
|
themeColors,
|
|
103
108
|
bounds: { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0 },
|
|
104
109
|
});
|
|
@@ -109,12 +114,19 @@ function handleClick(event: MouseEvent): void {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
// Find the closest element with data-upstart-hash
|
|
112
|
-
const
|
|
113
|
-
if (!
|
|
117
|
+
const rawElement = target.closest<HTMLElement>("[data-upstart-hash]");
|
|
118
|
+
if (!rawElement) {
|
|
114
119
|
console.info("[Upstart Editor] Click ignored: no ancestral element with data-upstart-hash found");
|
|
115
120
|
return;
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
// If the clicked element is nested inside a rich-panel editable ancestor, bubble up to it
|
|
124
|
+
// so that clicking any inline child (e.g. <strong>, <em>) edits the whole rich-panel region.
|
|
125
|
+
const richPanelAncestor = rawElement.parentElement?.closest<HTMLElement>(
|
|
126
|
+
'[data-upstart-editable-text-mode="rich-panel"]',
|
|
127
|
+
);
|
|
128
|
+
const element = richPanelAncestor ?? rawElement;
|
|
129
|
+
|
|
118
130
|
event.stopPropagation();
|
|
119
131
|
|
|
120
132
|
console.log("Element clicked dataset:", element.dataset);
|
|
@@ -123,7 +135,7 @@ function handleClick(event: MouseEvent): void {
|
|
|
123
135
|
const componentName = element.dataset.upstartComponent;
|
|
124
136
|
const filePath = element.dataset.upstartFile ?? "";
|
|
125
137
|
|
|
126
|
-
// If this element has editable-text
|
|
138
|
+
// If this element has editable-text, focus the TipTap editor (all modes now use inline TipTap)
|
|
127
139
|
if (element.dataset.upstartEditableText === "true") {
|
|
128
140
|
const proseMirror = element.querySelector<HTMLElement>(".ProseMirror");
|
|
129
141
|
if (proseMirror) {
|
|
@@ -142,11 +154,6 @@ function handleClick(event: MouseEvent): void {
|
|
|
142
154
|
}
|
|
143
155
|
}
|
|
144
156
|
|
|
145
|
-
// Check if this element (or a nearby ancestor) is a datasource record
|
|
146
|
-
const datasourceEl = target.closest<HTMLElement>("[data-upstart-datasource][data-upstart-record-id]");
|
|
147
|
-
const datasourceId = datasourceEl?.dataset.upstartDatasource;
|
|
148
|
-
const recordId = datasourceEl?.dataset.upstartRecordId;
|
|
149
|
-
|
|
150
157
|
// Read DaisyUI CSS custom property values from the iframe's document
|
|
151
158
|
const computedStyle = getComputedStyle(document.documentElement);
|
|
152
159
|
const themeColors: Record<string, string> = {};
|
|
@@ -156,6 +163,19 @@ function handleClick(event: MouseEvent): void {
|
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
const rect = element.getBoundingClientRect();
|
|
166
|
+
const bounds = {
|
|
167
|
+
top: rect.top,
|
|
168
|
+
left: rect.left,
|
|
169
|
+
width: rect.width,
|
|
170
|
+
height: rect.height,
|
|
171
|
+
right: rect.right,
|
|
172
|
+
bottom: rect.bottom,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Check if this element (or a nearby ancestor) is a datasource record
|
|
176
|
+
const datasourceEl = target.closest<HTMLElement>("[data-upstart-datasource][data-upstart-record-id]");
|
|
177
|
+
const datasourceId = datasourceEl?.dataset.upstartDatasource;
|
|
178
|
+
const recordId = datasourceEl?.dataset.upstartRecordId;
|
|
159
179
|
|
|
160
180
|
sendToParent({
|
|
161
181
|
type: "element-clicked",
|
|
@@ -167,14 +187,8 @@ function handleClick(event: MouseEvent): void {
|
|
|
167
187
|
datasourceId,
|
|
168
188
|
recordId,
|
|
169
189
|
themeColors,
|
|
170
|
-
bounds
|
|
171
|
-
|
|
172
|
-
left: rect.left,
|
|
173
|
-
width: rect.width,
|
|
174
|
-
height: rect.height,
|
|
175
|
-
right: rect.right,
|
|
176
|
-
bottom: rect.bottom,
|
|
177
|
-
},
|
|
190
|
+
bounds,
|
|
191
|
+
viewportWidth: window.innerWidth,
|
|
178
192
|
});
|
|
179
193
|
|
|
180
194
|
console.log("[Upstart Editor] Element clicked:", componentName, hash);
|
|
@@ -86,7 +86,15 @@ export function initUpstartEditor(): void {
|
|
|
86
86
|
sendToParent({ type: "editor-navigated" });
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// i18next integration: if the app exposes it on window.__i18next (set before hydration),
|
|
90
|
+
// wire it up so template variables (e.g. {{year}}) render as non-editable atoms.
|
|
91
|
+
const i18next = (globalThis as any).__i18next;
|
|
92
|
+
const getRawI18nTemplate = i18next
|
|
93
|
+
? (namespace: string, key: string) =>
|
|
94
|
+
i18next.getResource(i18next.language, namespace, key) as string | undefined
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
97
|
+
initTextEditor(getRawI18nTemplate ? { getRawI18nTemplate } : {});
|
|
90
98
|
initClickHandler();
|
|
91
99
|
initHoverOverlay();
|
|
92
100
|
initErrorHandler();
|
|
@@ -132,6 +140,18 @@ function handleParentMessage(event: MessageEvent): void {
|
|
|
132
140
|
if (el) {
|
|
133
141
|
el.className = message.className;
|
|
134
142
|
}
|
|
143
|
+
const STYLE_ID = "upstart-preview-style";
|
|
144
|
+
let styleEl = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
|
145
|
+
if (message.previewCSS) {
|
|
146
|
+
if (!styleEl) {
|
|
147
|
+
styleEl = document.createElement("style");
|
|
148
|
+
styleEl.id = STYLE_ID;
|
|
149
|
+
document.head.appendChild(styleEl);
|
|
150
|
+
}
|
|
151
|
+
styleEl.textContent = message.previewCSS;
|
|
152
|
+
} else if (styleEl) {
|
|
153
|
+
styleEl.textContent = "";
|
|
154
|
+
}
|
|
135
155
|
} else if (message.type === "request-scroll-position") {
|
|
136
156
|
sendToParent({ type: "scroll-position", x: window.scrollX, y: window.scrollY });
|
|
137
157
|
} else if (message.type === "restore-scroll-position") {
|