@upstart.gg/vite-plugins 0.1.59 → 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.
@@ -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
  }