@upstart.gg/vite-plugins 0.1.29 → 0.1.30

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.
Files changed (50) hide show
  1. package/dist/upstart-editor-api.d.ts +13 -1
  2. package/dist/upstart-editor-api.d.ts.map +1 -1
  3. package/dist/upstart-editor-api.js +59 -1
  4. package/dist/upstart-editor-api.js.map +1 -1
  5. package/dist/vite-plugin-upstart-attrs.d.ts +12 -7
  6. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  7. package/dist/vite-plugin-upstart-attrs.js +195 -3
  8. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  9. package/dist/vite-plugin-upstart-branding/plugin.d.ts +3 -3
  10. package/dist/vite-plugin-upstart-branding/plugin.d.ts.map +1 -1
  11. package/dist/vite-plugin-upstart-branding/plugin.js.map +1 -1
  12. package/dist/vite-plugin-upstart-branding/runtime.js.map +1 -1
  13. package/dist/vite-plugin-upstart-editor/plugin.d.ts +3 -3
  14. package/dist/vite-plugin-upstart-editor/plugin.d.ts.map +1 -1
  15. package/dist/vite-plugin-upstart-editor/plugin.js +4 -1
  16. package/dist/vite-plugin-upstart-editor/plugin.js.map +1 -1
  17. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js +27 -16
  18. package/dist/vite-plugin-upstart-editor/runtime/click-handler.js.map +1 -1
  19. package/dist/vite-plugin-upstart-editor/runtime/error-handler.js.map +1 -1
  20. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js +1 -1
  21. package/dist/vite-plugin-upstart-editor/runtime/hover-overlay.js.map +1 -1
  22. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts +1 -1
  23. package/dist/vite-plugin-upstart-editor/runtime/index.js +14 -2
  24. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  25. package/dist/{src/vite-plugin-upstart-editor → vite-plugin-upstart-editor}/runtime/state.d.ts +1 -1
  26. package/dist/vite-plugin-upstart-editor/runtime/state.d.ts.map +1 -0
  27. package/dist/vite-plugin-upstart-editor/runtime/state.js.map +1 -0
  28. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
  29. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +212 -28
  30. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
  31. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +16 -3
  32. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  33. package/dist/vite-plugin-upstart-editor/runtime/utils.js.map +1 -1
  34. package/dist/vite-plugin-upstart-theme.d.ts +3 -3
  35. package/dist/vite-plugin-upstart-theme.d.ts.map +1 -1
  36. package/dist/vite-plugin-upstart-theme.js +2 -2
  37. package/dist/vite-plugin-upstart-theme.js.map +1 -1
  38. package/package.json +7 -7
  39. package/src/tests/vite-plugin-upstart-attrs.test.ts +298 -37
  40. package/src/upstart-editor-api.ts +71 -0
  41. package/src/vite-plugin-upstart-attrs.ts +293 -5
  42. package/src/vite-plugin-upstart-editor/plugin.ts +11 -1
  43. package/src/vite-plugin-upstart-editor/runtime/click-handler.ts +35 -21
  44. package/src/vite-plugin-upstart-editor/runtime/index.ts +21 -1
  45. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +260 -41
  46. package/src/vite-plugin-upstart-editor/runtime/types.ts +17 -4
  47. package/src/vite-plugin-upstart-theme.ts +4 -1
  48. package/dist/src/vite-plugin-upstart-editor/runtime/state.d.ts.map +0 -1
  49. package/dist/src/vite-plugin-upstart-editor/runtime/state.js.map +0 -1
  50. /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
- // Registry entry for editable elements (text and className)
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 2: Text leaf elements — registry tracking + non-editable flag
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="false"');
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
- const classNameId =
85
- editableEl.dataset.upstartClassnameId ||
86
- editableEl.closest<HTMLElement>("[data-upstart-classname-id]")?.dataset.upstartClassnameId ||
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: editableEl.className,
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 element = target.closest<HTMLElement>("[data-upstart-hash]");
113
- if (!element) {
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="true", let TipTap handle text editing
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
- top: rect.top,
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
- initTextEditor();
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") {