@upstart.gg/vite-plugins 0.1.60 → 0.1.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/page-meta.js +533 -0
- package/dist/page-meta.js.map +1 -0
- package/dist/site-meta.d.ts +28 -0
- package/dist/site-meta.d.ts.map +1 -0
- package/dist/site-meta.js +207 -0
- package/dist/site-meta.js.map +1 -0
- package/dist/upstart-editor-api.d.ts +135 -1
- package/dist/upstart-editor-api.d.ts.map +1 -1
- package/dist/upstart-editor-api.js +756 -1
- package/dist/upstart-editor-api.js.map +1 -1
- package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-attrs.js +130 -10
- package/dist/vite-plugin-upstart-attrs.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.js +35 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +139 -14
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +3 -0
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
- package/package.json +8 -3
- package/src/page-meta.ts +678 -0
- package/src/site-meta.ts +226 -0
- package/src/tests/site-meta.test.ts +158 -0
- package/src/tests/upstart-editor-api-page-meta.test.ts +635 -0
- package/src/tests/vite-plugin-upstart-attrs.test.ts +224 -13
- package/src/upstart-editor-api.ts +941 -0
- package/src/vite-plugin-upstart-attrs.ts +253 -14
- package/src/vite-plugin-upstart-editor/runtime/index.ts +38 -0
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +141 -14
- package/src/vite-plugin-upstart-editor/runtime/types.ts +2 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
|
286
|
-
if (
|
|
287
|
-
const
|
|
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
|
-
|
|
596
|
-
|
|
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
|
|
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[] }
|