@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.
Files changed (32) hide show
  1. package/dist/page-meta.js +533 -0
  2. package/dist/page-meta.js.map +1 -0
  3. package/dist/site-meta.d.ts +28 -0
  4. package/dist/site-meta.d.ts.map +1 -0
  5. package/dist/site-meta.js +207 -0
  6. package/dist/site-meta.js.map +1 -0
  7. package/dist/upstart-editor-api.d.ts +135 -1
  8. package/dist/upstart-editor-api.d.ts.map +1 -1
  9. package/dist/upstart-editor-api.js +756 -1
  10. package/dist/upstart-editor-api.js.map +1 -1
  11. package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
  12. package/dist/vite-plugin-upstart-attrs.js +130 -10
  13. package/dist/vite-plugin-upstart-attrs.js.map +1 -1
  14. package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
  15. package/dist/vite-plugin-upstart-editor/runtime/index.js +35 -0
  16. package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
  17. package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
  18. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +139 -14
  19. package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
  20. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +3 -0
  21. package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
  22. package/package.json +8 -3
  23. package/src/page-meta.ts +678 -0
  24. package/src/site-meta.ts +226 -0
  25. package/src/tests/site-meta.test.ts +158 -0
  26. package/src/tests/upstart-editor-api-page-meta.test.ts +635 -0
  27. package/src/tests/vite-plugin-upstart-attrs.test.ts +224 -13
  28. package/src/upstart-editor-api.ts +941 -0
  29. package/src/vite-plugin-upstart-attrs.ts +253 -14
  30. package/src/vite-plugin-upstart-editor/runtime/index.ts +38 -0
  31. package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +141 -14
  32. package/src/vite-plugin-upstart-editor/runtime/types.ts +2 -0
@@ -314,24 +314,34 @@ export function transformWithOxc(code: string, filePath: string) {
314
314
  const tCallChildren = findTCallsInChildren(jsxNode, state.code, state);
315
315
  const allI18nKeys = [...transChildren, ...tCallChildren];
316
316
  const hasI18n = allI18nKeys.length > 0;
317
-
318
- if (hasI18n && !hasMixedNonI18nContent(jsxNode, state.code, state, state.constants)) {
317
+ const isMixed = hasI18n && hasMixedNonI18nContent(jsxNode, state.code, state, state.constants);
318
+
319
+ // Mixed content is normally disqualifying, but the very common
320
+ // `<label><Trans i18nKey="…" /> *</label>` shape is recoverable: the static
321
+ // text around the single i18n node is exported as prefix/suffix so the editor
322
+ // can keep it out of the saved translation.
323
+ const i18nAffixes = isMixed
324
+ ? detectI18nStaticAffixes(jsxNode, state.code, state, state.constants)
325
+ : null;
326
+
327
+ // The element must resolve to exactly ONE translation key at runtime, otherwise
328
+ // an inline edit could not be routed to the right key.
329
+ const i18nAttrValue = hasI18n
330
+ ? buildI18nAttributeValue(jsxNode, allI18nKeys, state.code, state, state.constants)
331
+ : null;
332
+
333
+ if (hasI18n && i18nAttrValue && (!isMixed || i18nAffixes)) {
319
334
  attributes.push('data-upstart-editable-text="true"');
320
335
  attributes.push('data-upstart-editable-text-mode="plain"');
321
336
 
322
- const hasDynamic = allI18nKeys.some((t) => t.keyExpr || t.nsExpr);
323
- if (hasDynamic) {
324
- // Build a runtime JSX template expression for dynamic i18n keys
325
- const parts = allI18nKeys.map((t) => {
326
- const nsPart = t.nsExpr ? `\${${t.nsExpr}}` : t.namespace;
327
- const keyPart = t.keyExpr ? `\${${t.keyExpr}}` : t.key;
328
- return `${nsPart}:${keyPart}`;
329
- });
330
- attributes.push(`data-upstart-i18n={\`${parts.join(",")}\`}`);
331
- } else {
332
- const keys = allI18nKeys.map((t: I18nKeyInfo) => escapeProp(t.fullKey)).join(",");
333
- attributes.push(`data-upstart-i18n="${keys}"`);
337
+ if (i18nAffixes?.prefix) {
338
+ attributes.push(`data-upstart-i18n-prefix="${escapeProp(i18nAffixes.prefix)}"`);
334
339
  }
340
+ if (i18nAffixes?.suffix) {
341
+ attributes.push(`data-upstart-i18n-suffix="${escapeProp(i18nAffixes.suffix)}"`);
342
+ }
343
+
344
+ attributes.push(`data-upstart-i18n=${i18nAttrValue}`);
335
345
 
336
346
  const allValueKeys = [...new Set(allI18nKeys.flatMap((t) => t.valueKeys ?? []))];
337
347
  if (allValueKeys.length > 0) {
@@ -1044,6 +1054,22 @@ function findTransInExpression(
1044
1054
  return;
1045
1055
  }
1046
1056
 
1057
+ // A fragment renders all of its children at once — collect every key it holds
1058
+ if (expr.type === "JSXFragment") {
1059
+ for (const child of expr.children) {
1060
+ if (child.type === "JSXElement") {
1061
+ const info = detectTransComponent(child as JSXElement, code, constants);
1062
+ if (info) results.push(info);
1063
+ } else if (child.type === "JSXExpressionContainer") {
1064
+ const inner = (child as any).expression;
1065
+ if (inner && inner.type !== "JSXEmptyExpression") {
1066
+ findTransInExpression(inner, code, results, constants);
1067
+ }
1068
+ }
1069
+ }
1070
+ return;
1071
+ }
1072
+
1047
1073
  if (expr.type === "LogicalExpression") {
1048
1074
  findTransInExpression(expr.left, code, results, constants);
1049
1075
  findTransInExpression(expr.right, code, results, constants);
@@ -1099,6 +1125,197 @@ function findTCallsInChildren(jsxElement: JSXElement, code: string, state: Trans
1099
1125
  return results;
1100
1126
  }
1101
1127
 
1128
+ // Source of a JS expression resolving to "namespace:key" for one i18n reference.
1129
+ function i18nKeyExpressionSource(info: I18nKeyInfo): string {
1130
+ if (info.keyExpr || info.nsExpr) {
1131
+ const nsPart = info.nsExpr ? `\${${info.nsExpr}}` : info.namespace;
1132
+ const keyPart = info.keyExpr ? `\${${info.keyExpr}}` : info.key;
1133
+ return `\`${nsPart}:${keyPart}\``;
1134
+ }
1135
+ return JSON.stringify(info.fullKey);
1136
+ }
1137
+
1138
+ interface I18nTernary {
1139
+ /** Source of the ternary condition, e.g. "isSubmitting" */
1140
+ test: string;
1141
+ consequent: I18nKeyInfo;
1142
+ alternate: I18nKeyInfo;
1143
+ }
1144
+
1145
+ // Detects an element whose only i18n content is a ternary between two translations,
1146
+ // e.g. `<button>{isSubmitting ? <Trans i18nKey="a" /> : <Trans i18nKey="b" />}</button>`.
1147
+ // Only one branch is ever rendered, so the key can be resolved at runtime instead of
1148
+ // being ambiguous. Returns null as soon as anything else produces i18n content.
1149
+ function detectI18nTernary(
1150
+ jsxNode: JSXElement,
1151
+ code: string,
1152
+ state: TransformState,
1153
+ constants: Map<string, string>,
1154
+ ): I18nTernary | null {
1155
+ const keysOf = (expr: unknown): I18nKeyInfo[] => {
1156
+ const found: I18nKeyInfo[] = [];
1157
+ findTransInExpression(expr, code, found, constants);
1158
+ findTCallInExpression(expr, code, found, state);
1159
+ return found;
1160
+ };
1161
+
1162
+ let ternary: I18nTernary | null = null;
1163
+
1164
+ for (const child of jsxNode.children) {
1165
+ if (child.type === "JSXElement") {
1166
+ // A <Trans> sibling renders at the same time as the ternary → ambiguous
1167
+ if (detectTransComponent(child as JSXElement, code, constants)) return null;
1168
+ continue;
1169
+ }
1170
+
1171
+ if (child.type !== "JSXExpressionContainer") continue;
1172
+
1173
+ const expr = (child as any).expression;
1174
+ if (!expr || expr.type === "JSXEmptyExpression") continue;
1175
+ if (keysOf(expr).length === 0) continue;
1176
+
1177
+ // A second i18n-producing child renders alongside the first → ambiguous
1178
+ if (ternary) return null;
1179
+ if (expr.type !== "ConditionalExpression" || !hasRange(expr.test)) return null;
1180
+
1181
+ const consequentKeys = keysOf(expr.consequent);
1182
+ const alternateKeys = keysOf(expr.alternate);
1183
+ if (consequentKeys.length !== 1 || alternateKeys.length !== 1) return null;
1184
+
1185
+ ternary = {
1186
+ test: code.slice(expr.test.start, expr.test.end),
1187
+ consequent: consequentKeys[0],
1188
+ alternate: alternateKeys[0],
1189
+ };
1190
+ }
1191
+
1192
+ return ternary;
1193
+ }
1194
+
1195
+ // Builds the source of the `data-upstart-i18n` attribute, which must resolve to a
1196
+ // single "namespace:key" at runtime so an inline edit can be routed to the right
1197
+ // translation:
1198
+ // "translation:home.title" — single static key
1199
+ // {`${ns}:${keyVar}`} — dynamic key and/or namespace
1200
+ // {isSubmitting ? "translation:a" : "translation:b"} — ternary between two translations
1201
+ // Returns null when the element renders several translations at once — the element
1202
+ // then stays non-editable rather than saving edits to a bogus key.
1203
+ function buildI18nAttributeValue(
1204
+ jsxNode: JSXElement,
1205
+ keys: I18nKeyInfo[],
1206
+ code: string,
1207
+ state: TransformState,
1208
+ constants: Map<string, string>,
1209
+ ): string | null {
1210
+ if (keys.length === 1) {
1211
+ const [info] = keys;
1212
+ if (info.keyExpr || info.nsExpr) {
1213
+ return `{${i18nKeyExpressionSource(info)}}`;
1214
+ }
1215
+ return `"${escapeProp(info.fullKey)}"`;
1216
+ }
1217
+
1218
+ const ternary = detectI18nTernary(jsxNode, code, state, constants);
1219
+ if (!ternary) return null;
1220
+
1221
+ const consequent = i18nKeyExpressionSource(ternary.consequent);
1222
+ const alternate = i18nKeyExpressionSource(ternary.alternate);
1223
+ return `{${ternary.test} ? ${consequent} : ${alternate}}`;
1224
+ }
1225
+
1226
+ // Returns the visible text of an element whose children are exclusively JSXText
1227
+ // (e.g. the "*" of `<span aria-hidden="true">*</span>`), or null when the element
1228
+ // contains anything dynamic. Such an element renders static text, so next to an
1229
+ // i18n node it must be treated as a static affix, never as editable content.
1230
+ function getStaticElementText(jsxElement: JSXElement): string | null {
1231
+ let text = "";
1232
+ for (const child of jsxElement.children) {
1233
+ if (child.type !== "JSXText") return null;
1234
+ text += normalizeJSXText((child as any).value as string);
1235
+ }
1236
+ return text.trim().length > 0 ? text : null;
1237
+ }
1238
+
1239
+ interface I18nStaticAffixes {
1240
+ /** Static text rendered before the i18n node, e.g. "" */
1241
+ prefix: string;
1242
+ /** Static text rendered after the i18n node, e.g. " *" */
1243
+ suffix: string;
1244
+ }
1245
+
1246
+ // Detects the "single i18n source surrounded by static text" shape, e.g.
1247
+ // <label><Trans i18nKey="contact.form.phone" /> *</label>
1248
+ // Returns the static text rendered before/after the i18n node (normalised the same
1249
+ // way React normalises JSXText), or null when the element does not match: several
1250
+ // i18n sources, non-i18n elements, or dynamic expressions among the children.
1251
+ // The affixes let the runtime editor strip the static parts from the editable
1252
+ // content so they never leak into the saved translation.
1253
+ function detectI18nStaticAffixes(
1254
+ jsxNode: JSXElement,
1255
+ code: string,
1256
+ state: TransformState,
1257
+ constants: Map<string, string>,
1258
+ ): I18nStaticAffixes | null {
1259
+ let prefix = "";
1260
+ let suffix = "";
1261
+ let seenI18n = false;
1262
+
1263
+ const append = (text: string): void => {
1264
+ if (seenI18n) suffix += text;
1265
+ else prefix += text;
1266
+ };
1267
+
1268
+ for (const child of jsxNode.children) {
1269
+ if (child.type === "JSXText") {
1270
+ // React only trims JSXText around newlines; a single-line text (e.g. the " "
1271
+ // or " *" between two elements) is rendered verbatim.
1272
+ const raw = (child as any).value as string;
1273
+ const text = /[\r\n]/.test(raw) ? normalizeJSXText(raw) : raw;
1274
+ if (text) append(text);
1275
+ continue;
1276
+ }
1277
+
1278
+ if (child.type === "JSXElement") {
1279
+ if (detectTransComponent(child as JSXElement, code, constants)) {
1280
+ if (seenI18n) return null;
1281
+ seenI18n = true;
1282
+ continue;
1283
+ }
1284
+ // A purely static element (e.g. <span aria-hidden="true">*</span>) is an affix
1285
+ const staticText = getStaticElementText(child as JSXElement);
1286
+ if (staticText === null) return null;
1287
+ append(staticText);
1288
+ continue;
1289
+ }
1290
+
1291
+ if (child.type === "JSXExpressionContainer") {
1292
+ const expr = (child as any).expression;
1293
+ if (!expr || expr.type === "JSXEmptyExpression") continue;
1294
+
1295
+ // Whitespace-only literal (e.g. {" "}) renders as static text
1296
+ if (expr.type === "Literal" && typeof expr.value === "string" && expr.value.trim() === "") {
1297
+ append(expr.value);
1298
+ continue;
1299
+ }
1300
+
1301
+ const results: I18nKeyInfo[] = [];
1302
+ findTCallInExpression(expr, code, results, state);
1303
+ findTransInExpression(expr, code, results, constants);
1304
+ if (results.length === 0) return null;
1305
+ if (seenI18n) return null;
1306
+ seenI18n = true;
1307
+ continue;
1308
+ }
1309
+
1310
+ // Fragments, spread children… — too complex to reason about
1311
+ return null;
1312
+ }
1313
+
1314
+ if (!seenI18n || (!prefix && !suffix)) return null;
1315
+
1316
+ return { prefix, suffix };
1317
+ }
1318
+
1102
1319
  // Returns true if the element mixes i18n content with non-i18n text/expressions.
1103
1320
  // In that case, inline editing would incorrectly overwrite the translation with
1104
1321
  // the full rendered text (including static parts like "© 2026 Company.").
@@ -1111,6 +1328,10 @@ function findTCallsInChildren(jsxElement: JSXElement, code: string, state: Trans
1111
1328
  // Mixed patterns (flagged):
1112
1329
  // "© John Doe." + <Trans> — non-whitespace JSXText alongside i18n
1113
1330
  // {year} + <Trans> — text-producing Identifier/MemberExpression alongside i18n
1331
+ // <span>*</span> + <Trans> — static sibling element rendering its own text
1332
+ //
1333
+ // Flagged elements can still be recovered by detectI18nStaticAffixes() when the
1334
+ // static parts merely surround a single i18n node.
1114
1335
  function hasMixedNonI18nContent(
1115
1336
  jsxNode: JSXElement,
1116
1337
  code: string,
@@ -1120,6 +1341,11 @@ function hasMixedNonI18nContent(
1120
1341
  for (const child of jsxNode.children) {
1121
1342
  if (child.type === "JSXText") {
1122
1343
  if (((child as any).value as string).trim().length > 0) return true;
1344
+ } else if (child.type === "JSXElement") {
1345
+ // A sibling element that renders its own text (e.g. <span aria-hidden="true">*</span>)
1346
+ // is not part of the translation — same hazard as raw JSXText.
1347
+ if (detectTransComponent(child as JSXElement, code, constants)) continue;
1348
+ if (hasVisibleTextContent(child as JSXElement)) return true;
1123
1349
  } else if (child.type === "JSXExpressionContainer") {
1124
1350
  const expr = (child as any).expression;
1125
1351
  if (!expr || expr.type === "JSXEmptyExpression") continue;
@@ -1153,6 +1379,19 @@ function hasMixedNonI18nContent(
1153
1379
  function findTCallInExpression(expr: any, code: string, results: I18nKeyInfo[], state: TransformState): void {
1154
1380
  if (!expr || !expr.type) return;
1155
1381
 
1382
+ // A fragment renders all of its children at once — collect every key it holds
1383
+ if (expr.type === "JSXFragment") {
1384
+ for (const child of expr.children) {
1385
+ if (child.type === "JSXExpressionContainer") {
1386
+ const inner = (child as any).expression;
1387
+ if (inner && inner.type !== "JSXEmptyExpression") {
1388
+ findTCallInExpression(inner, code, results, state);
1389
+ }
1390
+ }
1391
+ }
1392
+ return;
1393
+ }
1394
+
1156
1395
  if (expr.type === "CallExpression") {
1157
1396
  const info = detectTCall(expr, code, state);
1158
1397
  if (info) {
@@ -62,6 +62,43 @@ export function waitForHydration(callback: () => void): void {
62
62
  }
63
63
  }
64
64
 
65
+ /**
66
+ * Report the react-router id of the rendered route to the parent editor, so it can edit
67
+ * that route's `meta` export (page title, description…).
68
+ *
69
+ * The id is read from react-router's own data router rather than from `history.pushState`:
70
+ * the router pushes the new URL BEFORE it commits the new matches, so a route id read at
71
+ * pushState time would still be the previous page's. Subscribing gives us the id once the
72
+ * navigation is complete. The router global only appears during hydration, hence the retry.
73
+ */
74
+ function initRouteReporter(): void {
75
+ let lastReported: string | undefined;
76
+
77
+ const report = () => {
78
+ // biome-ignore lint/suspicious/noExplicitAny: react-router does not type its window globals
79
+ const matches = (window as any).__reactRouterDataRouter?.state?.matches;
80
+ const routeId = Array.isArray(matches) ? matches[matches.length - 1]?.route?.id : undefined;
81
+ if (typeof routeId !== "string" || routeId === lastReported) return;
82
+ lastReported = routeId;
83
+ sendToParent({ type: "editor-route", routeId });
84
+ };
85
+
86
+ const attach = (): boolean => {
87
+ // biome-ignore lint/suspicious/noExplicitAny: react-router does not type its window globals
88
+ const router = (window as any).__reactRouterDataRouter;
89
+ if (typeof router?.subscribe !== "function") return false;
90
+ router.subscribe(() => report());
91
+ report();
92
+ return true;
93
+ };
94
+
95
+ if (attach()) return;
96
+ let attempts = 0;
97
+ const timer = setInterval(() => {
98
+ if (attach() || ++attempts > 25) clearInterval(timer);
99
+ }, 200);
100
+ }
101
+
65
102
  /**
66
103
  * Initialize the Upstart editor runtime.
67
104
  */
@@ -115,6 +152,7 @@ export function initUpstartEditor(): void {
115
152
  initArrayControls();
116
153
  initFormGuard();
117
154
  initErrorHandler();
155
+ initRouteReporter();
118
156
 
119
157
  sendToParent({ type: "editor-ready", path: currentPath() });
120
158
  } catch (error) {
@@ -57,6 +57,46 @@ const TemplateVariable = Node.create({
57
57
  },
58
58
  });
59
59
 
60
+ /**
61
+ * An atomic inline node holding static text that surrounds an i18n node in the
62
+ * source (e.g. the " *" in `<label><Trans i18nKey="…" /> *</label>`).
63
+ * It stays visible and non-editable, and renderText() returns "" so getText()
64
+ * yields only the translation — the static part never leaks into the saved value.
65
+ */
66
+ const StaticAffix = Node.create({
67
+ name: "staticAffix",
68
+ group: "inline",
69
+ inline: true,
70
+ atom: true,
71
+ selectable: false,
72
+
73
+ addAttributes() {
74
+ return {
75
+ text: { default: "" },
76
+ };
77
+ },
78
+
79
+ parseHTML() {
80
+ return [{ tag: "span[data-static-affix]" }];
81
+ },
82
+
83
+ renderHTML({ node }) {
84
+ return [
85
+ "span",
86
+ {
87
+ "data-static-affix": "",
88
+ contenteditable: "false",
89
+ style: "cursor:default;user-select:none;white-space:pre;",
90
+ },
91
+ node.attrs.text,
92
+ ];
93
+ },
94
+
95
+ renderText() {
96
+ return "";
97
+ },
98
+ });
99
+
60
100
  /**
61
101
  * Remaps Enter to insert a <br> (hard break) instead of creating a new paragraph.
62
102
  * Used in inline-rich mode where block nodes are not allowed.
@@ -80,6 +120,74 @@ const DEFAULT_OPTIONS: Required<UpstartEditorOptions> = {
80
120
  getRawI18nTemplate: () => undefined,
81
121
  };
82
122
 
123
+ /**
124
+ * Parse a `data-upstart-i18n` value into its namespace and key.
125
+ *
126
+ * The build-time plugin guarantees a single "namespace:key" per element (a ternary
127
+ * between two translations is emitted as a runtime-resolved expression), but a stale
128
+ * build may still carry a comma-separated list of keys. Such a value is unresolvable —
129
+ * we cannot tell which translation the edited text belongs to — so it is rejected
130
+ * instead of being saved to a mangled key.
131
+ */
132
+ function parseI18nAttr(value: string | undefined): { namespace: string; key: string } | null {
133
+ if (!value) return null;
134
+ if (value.includes(",")) {
135
+ console.warn("[Upstart Editor] Ambiguous i18n key, refusing to save:", value);
136
+ return null;
137
+ }
138
+ const colonIdx = value.indexOf(":");
139
+ return colonIdx >= 0
140
+ ? { namespace: value.slice(0, colonIdx), key: value.slice(colonIdx + 1) }
141
+ : { namespace: "translation", key: value };
142
+ }
143
+
144
+ /**
145
+ * Static text emitted by the build-time plugin around a single i18n node
146
+ * (`data-upstart-i18n-prefix` / `data-upstart-i18n-suffix`).
147
+ */
148
+ function getAffixes(element: HTMLElement): { prefix: string; suffix: string } {
149
+ return {
150
+ prefix: element.dataset.upstartI18nPrefix ?? "",
151
+ suffix: element.dataset.upstartI18nSuffix ?? "",
152
+ };
153
+ }
154
+
155
+ /** Remove the static affixes from a rendered text to get the translation alone. */
156
+ function stripAffixes(element: HTMLElement, text: string): string {
157
+ const { prefix, suffix } = getAffixes(element);
158
+ let out = text;
159
+ if (prefix && out.startsWith(prefix)) out = out.slice(prefix.length);
160
+ if (suffix && out.endsWith(suffix)) out = out.slice(0, out.length - suffix.length);
161
+ return out;
162
+ }
163
+
164
+ /** Re-attach the static affixes to a translation for display purposes. */
165
+ function applyAffixes(element: HTMLElement, text: string): string {
166
+ const { prefix, suffix } = getAffixes(element);
167
+ return `${prefix}${text}${suffix}`;
168
+ }
169
+
170
+ /**
171
+ * Wrap the editable inline nodes with non-editable StaticAffix nodes so the static
172
+ * parts stay visible while remaining outside of the edited (and saved) content.
173
+ */
174
+ function buildAffixDocument(element: HTMLElement, inlineNodes: object[]): object {
175
+ const { prefix, suffix } = getAffixes(element);
176
+ const content: object[] = [...inlineNodes];
177
+ if (prefix) content.unshift({ type: "staticAffix", attrs: { text: prefix } });
178
+ if (suffix) content.push({ type: "staticAffix", attrs: { text: suffix } });
179
+ return { type: "doc", content: [{ type: "paragraph", content }] };
180
+ }
181
+
182
+ /** Extract the inline nodes of a single-paragraph document produced above. */
183
+ function getInlineNodes(content: string | object): object[] {
184
+ if (typeof content === "string") {
185
+ return content ? [{ type: "text", text: content }] : [];
186
+ }
187
+ const paragraph = (content as { content?: { content?: object[] }[] }).content?.[0];
188
+ return paragraph?.content ?? [];
189
+ }
190
+
83
191
  /**
84
192
  * Parse a raw i18n template (e.g. "Copyright {{year}}") together with the
85
193
  * already-rendered text (e.g. "Copyright 2026") and produce a TipTap JSON
@@ -268,7 +376,9 @@ function activateEditor(element: HTMLElement, hash: string, options: Required<Up
268
376
  }
269
377
 
270
378
  function createPlainTextEditor(element: HTMLElement, options: Required<UpstartEditorOptions>): Editor {
271
- const renderedText = element.textContent ?? "";
379
+ // A label like `<Trans i18nKey="…" /> *` renders the static " *" alongside the
380
+ // translation: strip it so only the translation becomes editable.
381
+ const renderedText = stripAffixes(element, element.textContent ?? "");
272
382
 
273
383
  let content: string | object = renderedText;
274
384
  const extraExtensions: ReturnType<typeof Node.create>[] = [];
@@ -282,12 +392,9 @@ function createPlainTextEditor(element: HTMLElement, options: Required<UpstartEd
282
392
  // Check for i18n template variables (e.g. data-i18n-values="year,month")
283
393
  const i18nValueKeys = element.dataset.i18nValues?.split(",").filter(Boolean) ?? [];
284
394
  if (i18nValueKeys.length > 0) {
285
- const i18nAttr = element.dataset.upstartI18n;
286
- if (i18nAttr) {
287
- const colonIdx = i18nAttr.indexOf(":");
288
- const namespace = colonIdx >= 0 ? i18nAttr.slice(0, colonIdx) : "translation";
289
- const key = colonIdx >= 0 ? i18nAttr.slice(colonIdx + 1) : i18nAttr;
290
- const rawTemplate = options.getRawI18nTemplate(namespace, key);
395
+ const parsed = parseI18nAttr(element.dataset.upstartI18n);
396
+ if (parsed) {
397
+ const rawTemplate = options.getRawI18nTemplate(parsed.namespace, parsed.key);
291
398
  if (rawTemplate) {
292
399
  content = buildI18nContent(rawTemplate, renderedText);
293
400
  extraExtensions.push(TemplateVariable);
@@ -296,6 +403,12 @@ function createPlainTextEditor(element: HTMLElement, options: Required<UpstartEd
296
403
  }
297
404
  }
298
405
 
406
+ const { prefix, suffix } = getAffixes(element);
407
+ if (prefix || suffix) {
408
+ extraExtensions.push(StaticAffix);
409
+ content = buildAffixDocument(element, getInlineNodes(content));
410
+ }
411
+
299
412
  element.textContent = "";
300
413
  let hasChanged = false;
301
414
 
@@ -587,15 +700,25 @@ function syncI18nSiblings(sourceElement: HTMLElement): void {
587
700
 
588
701
  const siblingInstance = activeEditors.get(sibling);
589
702
 
703
+ const { prefix, suffix } = getAffixes(sibling);
704
+ const hasAffixes = Boolean(prefix || suffix);
705
+
590
706
  if (siblingInstance) {
591
707
  console.log(
592
708
  `[Upstart Editor] Updating sibling editor (hash: ${siblingInstance.hash}) with new content`,
593
709
  siblingInstance,
594
710
  );
595
- sibling.innerText = plainContent;
596
- siblingInstance.editor.chain().selectAll().insertContent(plainContent).run();
711
+ if (hasAffixes) {
712
+ // setContent rebuilds the StaticAffix nodes, which insertContent would drop
713
+ siblingInstance.editor.commands.setContent(
714
+ buildAffixDocument(sibling, plainContent ? [{ type: "text", text: plainContent }] : []),
715
+ );
716
+ } else {
717
+ sibling.innerText = plainContent;
718
+ siblingInstance.editor.chain().selectAll().insertContent(plainContent).run();
719
+ }
597
720
  } else {
598
- sibling.textContent = plainContent;
721
+ sibling.textContent = applyAffixes(sibling, plainContent);
599
722
  }
600
723
  }
601
724
  } finally {
@@ -616,14 +739,18 @@ function saveText(element: HTMLElement, newText: string): void {
616
739
  payload: { action: "editTextDirect", id: dataset.upstartId!, content: newText },
617
740
  });
618
741
  } else {
619
- const [namespace, key] = dataset.upstartI18n?.split(":") ?? [];
742
+ const parsed = parseI18nAttr(dataset.upstartI18n);
743
+ if (!parsed) {
744
+ console.warn("[Upstart Editor] No resolvable i18n key on element, edit not saved:", element);
745
+ return;
746
+ }
620
747
  sendToParent({
621
748
  type: "text-edit",
622
749
  payload: {
623
750
  action: "editText",
624
751
  content: newText,
625
- namespace,
626
- key,
752
+ namespace: parsed.namespace,
753
+ key: parsed.key,
627
754
  language: document.documentElement.lang,
628
755
  },
629
756
  });
@@ -659,7 +786,7 @@ function destroyEditor(element: HTMLElement): void {
659
786
 
660
787
  // Update element content with the final edited text
661
788
  if (isPlainMode) {
662
- instance.element.textContent = finalContent;
789
+ instance.element.textContent = applyAffixes(instance.element, finalContent);
663
790
  } else {
664
791
  instance.element.innerHTML = finalContent;
665
792
  }
@@ -92,6 +92,8 @@ export type EditorMessage =
92
92
  }
93
93
  | { type: "editor-ready"; path?: string }
94
94
  | { type: "editor-navigated"; path?: string }
95
+ /** React-router id of the route currently rendered (e.g. "routes/_layout._index"). */
96
+ | { type: "editor-route"; routeId: string }
95
97
  | { type: "scroll-position"; x: number; y: number }
96
98
  | { type: "editor-error"; error: string }
97
99
  | { type: "selection-changed"; selection: SelectionRef[] }