@upstart.gg/vite-plugins 0.1.60 → 0.1.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/vite-plugin-upstart-attrs.d.ts.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/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/package.json +3 -3
- package/src/tests/vite-plugin-upstart-attrs.test.ts +224 -13
- package/src/vite-plugin-upstart-attrs.ts +253 -14
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +141 -14
|
@@ -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
|
}
|