@wix/himalaya-cli 0.1158.0 → 0.1160.0

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 (63) hide show
  1. package/dist/cli.mjs +2 -0
  2. package/package.json +2 -2
  3. package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +1 -1
  4. package/templates/beauty-salon-owner/dev/index.ts +382 -194
  5. package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +1 -1
  6. package/templates/beauty-salon-owner/tier5-src/_shared/owner-editor-value.ts +56 -0
  7. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
  8. package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +1 -1
  9. package/templates/brandy/config.ts +14 -3
  10. package/templates/brandy/dev/index.ts +339 -2
  11. package/templates/brandy/tier5-src/_shared/himi-copy.ts +56 -0
  12. package/templates/brandy/tier5-src/himi-notification-settings/index.ts +130 -0
  13. package/templates/brandy/tokens.json +10 -0
  14. package/templates/community-club/tier5-src/_shared/himi-copy.ts +1 -1
  15. package/templates/community-club-owner/dev/index.ts +382 -194
  16. package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +1 -1
  17. package/templates/community-club-owner/tier5-src/_shared/owner-editor-value.ts +56 -0
  18. package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
  19. package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +1 -1
  20. package/templates/events-meetups-owner/dev/index.ts +382 -194
  21. package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +1 -1
  22. package/templates/events-meetups-owner/tier5-src/_shared/owner-editor-value.ts +56 -0
  23. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
  24. package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +1 -1
  25. package/templates/gym-fitness-owner/dev/index.ts +382 -194
  26. package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +1 -1
  27. package/templates/gym-fitness-owner/tier5-src/_shared/owner-editor-value.ts +56 -0
  28. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
  29. package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +1 -1
  30. package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +1 -1
  31. package/templates/nonprofit-cause-owner/dev/index.ts +382 -194
  32. package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +1 -1
  33. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-editor-value.ts +56 -0
  34. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
  35. package/templates/owner-lite/dev/index.ts +382 -194
  36. package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +1 -1
  37. package/templates/owner-lite/tier5-src/_shared/owner-editor-value.ts +56 -0
  38. package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
  39. package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +1 -1
  40. package/templates/podcast-creator-owner/dev/index.ts +382 -194
  41. package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +1 -1
  42. package/templates/podcast-creator-owner/tier5-src/_shared/owner-editor-value.ts +56 -0
  43. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
  44. package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +1 -1
  45. package/templates/portfolio-creative-owner/dev/index.ts +382 -194
  46. package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +1 -1
  47. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-editor-value.ts +56 -0
  48. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
  49. package/templates/restaurant/tier5-src/_shared/himi-copy.ts +1 -1
  50. package/templates/restaurant-owner/dev/index.ts +382 -194
  51. package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +1 -1
  52. package/templates/restaurant-owner/tier5-src/_shared/owner-editor-value.ts +56 -0
  53. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
  54. package/templates/retail-store/tier5-src/_shared/himi-copy.ts +1 -1
  55. package/templates/retail-store-owner/dev/index.ts +382 -194
  56. package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +1 -1
  57. package/templates/retail-store-owner/tier5-src/_shared/owner-editor-value.ts +56 -0
  58. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
  59. package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +1 -1
  60. package/templates/yoga-studio-owner/dev/index.ts +382 -194
  61. package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +1 -1
  62. package/templates/yoga-studio-owner/tier5-src/_shared/owner-editor-value.ts +56 -0
  63. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +132 -46
@@ -0,0 +1,56 @@
1
+ // [rico] #3371 — how an owner form reads a RichContentEditor out of `submit(s, values)`.
2
+ //
3
+ // The editor writes its Ricos document into the Form's value bag as a JSON STRING under its `name`,
4
+ // on every change and again on blur. The value is ABSENT when the editor never became ready (the
5
+ // engine page is not bundled, failed, or timed out): the stored body must then stay exactly as it is
6
+ // (user decision, 2026-09-26 — save everything but the body). A document is written exactly as the
7
+ // editor produced it: never re-encoded (encodeRichContent is the VIEWER's; it rewrites media ids and
8
+ // drops nodes the owner never touched).
9
+ // Inside the platform source this import is a relative path, so node test runs resolve it; an app's
10
+ // copy imports "@himalaya/wix-rich-content".
11
+ import { parseRicosJson, RICH_CONTENT_LIMITS, type RicosDocument } from "@himalaya/wix-rich-content";
12
+
13
+ export type EditorValue =
14
+ | { kind: "absent" } // leave the stored body untouched
15
+ | { kind: "unchanged" } // byte-identical to the seed: nothing to write
16
+ | { kind: "doc"; doc: RicosDocument; json: string } // write `doc`; `json` is the editor's own string
17
+ | { kind: "too_large" } // past the rich-content limits: save the rest, say why
18
+ | { kind: "invalid" }; // not a Ricos document: refuse, write nothing
19
+
20
+ function utf8Bytes(s: string): number {
21
+ let n = 0;
22
+ for (let i = 0; i < s.length; i++) {
23
+ const c = s.charCodeAt(i);
24
+ if (c < 0x80) n += 1;
25
+ else if (c < 0x800) n += 2;
26
+ else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { n += 4; i++; }
27
+ else n += 3;
28
+ }
29
+ return n;
30
+ }
31
+
32
+ /** Every viewer renders a body past the rich-content limits as its fallback, so it is never written.
33
+ * The viewer's order: bytes (before parsing), then depth, then the node count. */
34
+ function overLimits(doc: RicosDocument): boolean {
35
+ let count = 0;
36
+ const work: Array<[unknown, number]> = doc.nodes.map((n) => [n, 1] as [unknown, number]);
37
+ while (work.length) {
38
+ const [n, depth] = work.pop()!;
39
+ if (!n || typeof n !== "object" || Array.isArray(n)) continue;
40
+ if (depth > RICH_CONTENT_LIMITS.maxDepth) return true;
41
+ if (++count > RICH_CONTENT_LIMITS.maxNodes) return true;
42
+ const kids = (n as { nodes?: unknown }).nodes;
43
+ if (Array.isArray(kids)) for (const k of kids) work.push([k, depth + 1]);
44
+ }
45
+ return false;
46
+ }
47
+
48
+ /** `raw` is `values[name]`; `seededJson` is the string the form published as the editor's seed. */
49
+ export function editorValue(raw: unknown, seededJson: string): EditorValue {
50
+ if (typeof raw !== "string" || raw === "") return { kind: "absent" };
51
+ if (seededJson !== "" && raw === seededJson) return { kind: "unchanged" };
52
+ if (utf8Bytes(raw) > RICH_CONTENT_LIMITS.maxBytes) return { kind: "too_large" };
53
+ const doc = parseRicosJson(raw);
54
+ if (!doc) return { kind: "invalid" };
55
+ return overLimits(doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
56
+ }
@@ -3,7 +3,10 @@
3
3
  // the post detail ("Edit"). On save it lands a draft; publish is on the detail
4
4
  // screen (himiOwnerLitePost.publish). Bound by buildForm ($service:himiOwnerLitePostForm.*).
5
5
 
6
- import { postBodyText, hasRichBody, plainPostBody, postCoverUrl } from "../_shared/owner-post-content.js";
6
+ import { postBodyText, plainPostBody, postCoverUrl } from "../_shared/owner-post-content.js";
7
+ import { editorValue } from "../_shared/owner-editor-value.js";
8
+ import { parseRicosJson } from "@himalaya/wix-rich-content";
9
+ import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
7
10
  import { wixCall } from "@himalaya/wix-site-media";
8
11
  import { wixMediaUrl, WIX_DETAIL_IMAGE } from "../_shared/himi-store.js";
9
12
  import { defineService } from "@himalaya/state";
@@ -34,9 +37,21 @@ interface PostFormState {
34
37
  excerpt: string;
35
38
  body: string;
36
39
  richContent: WixRichContent | null;
37
- bodyProtected: boolean;
38
- bodyEditable: boolean;
39
- bodyNote: string;
40
+ /** [rico] #3371 — the post's raw `richContent` as a JSON string: the RichContentEditor's seed
41
+ * (`documentPath`). "" for a new post, and until a load lands (see the boot reset below).
42
+ * Re-based to the saved document after each save. */
43
+ bodyJson: string;
44
+ /** The "Post details" sheet (excerpt + cover) is open. */
45
+ detailsOpen: boolean;
46
+ /** The editor's on_change fired since the body was last loaded or saved. The bag ALWAYS holds a
47
+ * document once the editor is ready — the engine's own serialization of the seed, which is not
48
+ * byte-equal to it — so only this says the owner actually edited the body. */
49
+ bodyDirty: boolean;
50
+ /** Edit mode, until `load` lands: the descriptor hides the editor, because a provisional editor
51
+ * the owner typed into keeps that text when the real document arrives (the seed latch). */
52
+ bodyPending: boolean;
53
+ /** When a save last wrote the body (ms), so its own trailing on_change does not undo "Post updated.". */
54
+ bodySavedAt: number;
40
55
  coverUrl: string;
41
56
  originalCoverUrl: string;
42
57
  coverPreview: string;
@@ -88,6 +103,13 @@ function boolString(value: unknown, fallback: string): string {
88
103
  if (typeof value === "string") return value === "false" ? "false" : value === "true" ? "true" : fallback;
89
104
  return fallback;
90
105
  }
106
+ /** What every load that publishes a document resets: the editor may show it, nothing is edited yet. */
107
+ const BODY_LOADED = { bodyPending: false, bodyDirty: false, bodySavedAt: 0, detailsOpen: false } as const;
108
+ /** How long after a body save its own debounced on_change may still arrive (the arm waits 500 ms). */
109
+ const TRAILING_CHANGE_MS = 1500;
110
+ const valueOf = (arg: unknown): string =>
111
+ String(typeof arg === "string" ? arg : (arg as { value?: unknown; text?: unknown } | undefined)?.value ?? (arg as { text?: unknown } | undefined)?.text ?? "");
112
+ const docJson = (doc: unknown): string => (doc ? JSON.stringify(doc) : "");
91
113
 
92
114
 
93
115
  function shape(s: Partial<PostFormState>): PostFormState {
@@ -102,9 +124,11 @@ function shape(s: Partial<PostFormState>): PostFormState {
102
124
  excerpt: s.excerpt ?? "",
103
125
  body: s.body ?? "",
104
126
  richContent: s.richContent ?? null,
105
- bodyProtected: hasRichBody(s.richContent),
106
- bodyEditable: !hasRichBody(s.richContent),
107
- bodyNote: hasRichBody(s.richContent) ? copyParam("ownerPostForm.richBodyNote", "Formatting and embedded media are preserved. Edit this body in your site dashboard; title, summary and cover can be changed here.") : "",
127
+ bodyJson: s.bodyJson ?? "",
128
+ detailsOpen: s.detailsOpen ?? false,
129
+ bodyDirty: s.bodyDirty ?? false,
130
+ bodyPending: s.bodyPending ?? false,
131
+ bodySavedAt: s.bodySavedAt ?? 0,
108
132
  coverUrl: s.coverUrl ?? "",
109
133
  originalCoverUrl: s.originalCoverUrl ?? "",
110
134
  coverPreview: wixMediaUrl(s.coverUrl ?? "", WIX_DETAIL_IMAGE),
@@ -133,12 +157,23 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
133
157
  return shape({ ...s, ...demoRefusalPatch(copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE)) });
134
158
  }
135
159
 
160
+ // [rico] #3371 — the RichContentEditor's image tool (the editor's upload contract). The component picks
161
+ // and PUTs the bytes; this service mints the signed Wix Media URL with the worker's own wixApi
162
+ // (@himalaya/wix-site-media rides the `wix_api` grant), so no token reaches the page. Registered
163
+ // BEFORE the form's service on purpose: the worker test harnesses treat a bundle's
164
+ // LAST defineService as its service.
165
+ ricosUploadService("himiOwnerLitePostFormUploads");
166
+
136
167
  const svc = defineService<PostFormState, {
137
168
  load: (s: PostFormState, arg?: unknown) => Promise<PostFormState>;
138
169
  setCover: (s: PostFormState, arg?: unknown) => PostFormState;
170
+ setExcerpt: (s: PostFormState, arg?: unknown) => PostFormState;
171
+ detailsOpen: (s: PostFormState) => PostFormState;
172
+ detailsClose: (s: PostFormState) => PostFormState;
173
+ bodyChanged: (s: PostFormState, arg?: unknown) => PostFormState;
139
174
  submit: (s: PostFormState, values?: Record<string, unknown>) => Promise<PostFormState>;
140
175
  }>("himiOwnerLitePostForm", {
141
- state: shape({ mode: startMode, id: initialInput.id ?? "" }),
176
+ state: shape({ mode: startMode, id: initialInput.id ?? "", bodyPending: startMode === "edit" }),
142
177
  actions: {
143
178
  load: async (s, arg) => {
144
179
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
@@ -146,13 +181,13 @@ const svc = defineService<PostFormState, {
146
181
  // bakes no id/mode); the onAppear `id` param is the #570 suffixed-build
147
182
  // fallback. An id ⇒ edit; no id anywhere ⇒ the create shell.
148
183
  const id = navInputId() || idFrom(arg, s.id);
149
- if (!id) return shape({ ...s, mode: "create", id: "", status: "idle", error: "", demoNote: isOwnerPreviewDemo() ? copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) : "" });
184
+ if (!id) return shape({ ...s, ...BODY_LOADED, mode: "create", id: "", bodyJson: "", status: "idle", error: "", demoNote: isOwnerPreviewDemo() ? copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) : "" });
150
185
  if (isOwnerPreviewDemo()) {
151
186
  const d = DEMO_POSTS.find((x) => x.id === id);
152
187
  if (d) {
153
188
  return shape({
154
189
  ...s, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
155
- title: d.title, excerpt: d.excerpt, body: d.body, isFeatured: "false",
190
+ ...BODY_LOADED, title: d.title, excerpt: d.excerpt, body: d.body, bodyJson: docJson(plainPostBody(d.body)), isFeatured: "false",
156
191
  status: "idle", error: "", savedMessage: "", demoNote: copyParam("ownerCommon.demoNote", DEMO_NOTE),
157
192
  });
158
193
  }
@@ -166,11 +201,13 @@ const svc = defineService<PostFormState, {
166
201
  ...(p.richContent ? { richContent: JSON.stringify(p.richContent) } : {}),
167
202
  coverUrl: postCoverUrl(p), isFeatured: p.isFeatured ? "true" : "false" });
168
203
  return shape({
169
- ...s, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
204
+ ...s, ...BODY_LOADED, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
170
205
  title: (p as any).title ?? "",
171
206
  excerpt: (p as any).excerpt ?? "",
172
207
  body: postBodyText(p.richContent),
173
208
  richContent: p.richContent ?? null,
209
+ // The editor's seed is the fetched document, byte for byte: never encodeRichContent.
210
+ bodyJson: docJson(p.richContent),
174
211
  coverUrl: postCoverUrl(p), originalCoverUrl: postCoverUrl(p),
175
212
  isFeatured: (p as any).isFeatured ? "true" : "false",
176
213
  status: "idle", error: "", savedMessage: "",
@@ -180,11 +217,11 @@ const svc = defineService<PostFormState, {
180
217
  const cached = await cacheGet(id);
181
218
  if (cached) {
182
219
  return shape({
183
- ...s, mode: "edit", id, heading: "Edit post",
220
+ ...s, ...BODY_LOADED, mode: "edit", id, heading: "Edit post",
184
221
  title: String(cached.title ?? cached.name ?? ""),
185
222
  excerpt: String(cached.excerpt ?? cached.summary ?? ""),
186
223
  body: String(cached.body ?? ""),
187
- richContent: cached.richContent ? JSON.parse(String(cached.richContent)) : null,
224
+ ...((raw: string) => ({ richContent: raw ? JSON.parse(raw) : null, bodyJson: raw || docJson(plainPostBody(String(cached.body ?? ""))) }))(cached.richContent ? String(cached.richContent) : ""),
188
225
  coverUrl: String(cached.coverUrl ?? ""), originalCoverUrl: String(cached.coverUrl ?? ""),
189
226
  isFeatured: cached.isFeatured === "true" ? "true" : "false",
190
227
  status: "idle", error: "", savedMessage: "",
@@ -195,7 +232,7 @@ const svc = defineService<PostFormState, {
195
232
  if (d) {
196
233
  return shape({
197
234
  ...s, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
198
- title: d.title, excerpt: d.excerpt, body: d.body, isFeatured: "false",
235
+ ...BODY_LOADED, title: d.title, excerpt: d.excerpt, body: d.body, bodyJson: docJson(plainPostBody(d.body)), isFeatured: "false",
199
236
  status: "idle", error: "", savedMessage: "", demoNote: copyParam("ownerCommon.demoNote", DEMO_NOTE),
200
237
  });
201
238
  }
@@ -203,46 +240,85 @@ const svc = defineService<PostFormState, {
203
240
  return shape({ ...s, mode: "edit", id, status: "error", error: ownerLoadError(e) });
204
241
  }
205
242
  },
206
- setCover: (s, arg) => {
207
- const a = arg as { value?: unknown; text?: unknown } | undefined;
208
- const coverUrl = String(typeof arg === "string" ? arg : a?.value ?? a?.text ?? "").trim();
209
- return shape({ ...s, coverUrl, error: "" });
243
+ setCover: (s, arg) => shape({ ...s, coverUrl: valueOf(arg).trim(), error: "" }),
244
+ setExcerpt: (s, arg) => shape({ ...s, excerpt: valueOf(arg), error: "" }),
245
+ detailsOpen: (s) => shape({ ...s, detailsOpen: true }),
246
+ detailsClose: (s) => shape({ ...s, detailsOpen: false }),
247
+ // The editor's debounced `on_change` carries `args.dirty` only; the document stays in the form
248
+ // bag. A body edited after a save is no longer saved, so drop the stale confirmation — unless
249
+ // this is the trailing on_change of the very edit that save wrote.
250
+ bodyChanged: (s) => {
251
+ const trailing = s.status === "saved" && s.bodySavedAt > 0 && Date.now() - s.bodySavedAt < TRAILING_CHANGE_MS;
252
+ return shape({ ...s, bodyDirty: true, ...(trailing ? {} : { savedMessage: "", status: s.status === "saved" ? "idle" : s.status }) });
210
253
  },
211
254
  submit: async (s, values) => {
212
255
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
213
256
  const v = values ?? {};
214
257
  const title = String(v.title ?? s.title).trim();
258
+ // Excerpt and cover live in the "Post details" sheet, outside the Form: worker state carries them.
215
259
  const excerpt = String(v.excerpt ?? s.excerpt).trim();
216
- const body = String(v.body ?? s.body);
217
260
  const coverUrl = String(v.coverUrl ?? s.coverUrl).trim();
218
261
  // Queue replay carries the target explicitly; it may boot a fresh worker.
219
262
  const targetId = String(v.id ?? s.id);
220
263
  const editing = (v.mode ?? s.mode) === "edit" && !!targetId;
221
- const preserveBody = editing && (typeof v.preserveBody === "string"
222
- ? v.preserveBody === "true" : body === s.body);
264
+ // A queued write replays in a FRESH worker and says so with `preserveBody`. This bundle's items
265
+ // carry the editor's string as `richContentJson`; the pre-editor bundle's carry a plain `body`.
266
+ const replay = typeof v.preserveBody === "string";
223
267
  const coverChanged = typeof v.coverChanged === "string"
224
268
  ? v.coverChanged === "true" : coverUrl !== s.originalCoverUrl;
225
- if (!preserveBody && s.bodyProtected) return shape({ ...s, status: "error", error: copyParam("ownerPostForm.richBodyError", "This body contains formatting or media. Edit it in your site dashboard to keep that content intact."), savedMessage: "" });
226
269
  if (coverChanged && coverUrl && !/^https:\/\/[^\s/]+(?:\/[^\s]*)?$/.test(coverUrl)) return shape({ ...s, status: "error", error: copyParam("ownerPostForm.coverInvalid", "Enter an HTTPS image URL."), savedMessage: "" });
227
270
  const isFeatured = boolString(v.isFeatured, s.isFeatured) === "true";
271
+ // Required fields are checked HERE, not only by the renderer: native Save does not gate on the
272
+ // RichContentEditor, and a queue replay skips the renderer entirely.
228
273
  if (!title) return shape({ ...s, status: "error", error: copyParam("ownerPostForm.titleRequired", "Title is required"), savedMessage: "" });
274
+
275
+ // The body to WRITE — the editor's own document, never re-encoded. null leaves the stored body as it is.
276
+ let richContent: WixRichContent | null = null;
277
+ let bodyRaw = "";
278
+ let bodyTooLarge = false;
279
+ if (replay) {
280
+ const queued = typeof v.richContentJson === "string" ? parseRicosJson(v.richContentJson) : null;
281
+ if (queued) { richContent = queued as unknown as WixRichContent; bodyRaw = String(v.richContentJson); }
282
+ else if (v.preserveBody === "false") { richContent = plainPostBody(String(v.body ?? "")); bodyRaw = docJson(richContent); }
283
+ } else {
284
+ // Only an edit the editor reported counts: before load lands the editor is hidden (a body
285
+ // here was typed into the provisional empty editor), and an untouched bag holds the engine's
286
+ // re-serialization of the seed, which must not be written back.
287
+ const ev = s.bodyPending || !s.bodyDirty ? { kind: "absent" as const } : editorValue(v.body, s.bodyJson);
288
+ if (ev.kind === "invalid") return shape({ ...s, status: "error", error: copyParam("ownerPostForm.bodyInvalid", "Couldn’t read the post body from the editor. Nothing was saved; try again."), savedMessage: "" });
289
+ if (ev.kind === "doc") { richContent = ev.doc as unknown as WixRichContent; bodyRaw = ev.json; }
290
+ // A body past the rich-content limits is not written (every viewer would show its
291
+ // fallback); the other fields still save, and the screen says why.
292
+ bodyTooLarge = ev.kind === "too_large";
293
+ // "absent": the editor never became ready — save the rest, leave the body (user decision 2026-09-26).
294
+ // "unchanged": the editor handed back exactly its seed — nothing to write.
295
+ }
296
+ const bodyEdited = richContent !== null;
229
297
  const draftPost: WixDraftPostWrite & { media?: Record<string, unknown> } = {
230
298
  title,
231
- ...(!preserveBody ? { richContent: plainPostBody(body) } : {}),
299
+ ...(bodyEdited ? { richContent: richContent! } : {}),
232
300
  isFeatured,
233
301
  excerpt,
234
302
  };
235
303
 
236
304
  // Cache the full document so later offline metadata edits retain its body.
237
- let richContent = preserveBody ? s.richContent : plainPostBody(body);
238
- const localRow = (id: string): DbEntity => ({ id, name: title, title, excerpt, body, coverUrl, ...(richContent ? { richContent: JSON.stringify(richContent) } : {}), isFeatured: isFeatured ? "true" : "false", postStatus: "Draft", postStatusRaw: "DRAFT" });
305
+ let kept: WixRichContent | null = bodyEdited ? richContent : (replay ? null : s.richContent);
306
+ const localRow = (id: string): DbEntity => ({ id, name: title, title, excerpt, body: postBodyText(kept), coverUrl, ...(kept ? { richContent: JSON.stringify(kept) } : {}), isFeatured: isFeatured ? "true" : "false", postStatus: "Draft", postStatusRaw: "DRAFT" });
239
307
  const localId = editing ? targetId : `local-${Date.now()}`;
308
+ const savedState = (id: string, savedMessage: string) => shape({
309
+ ...s, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
310
+ title, excerpt, body: postBodyText(kept), richContent: kept, bodyJson: bodyEdited ? bodyRaw : s.bodyJson,
311
+ bodyDirty: bodyEdited ? false : s.bodyDirty, bodySavedAt: bodyEdited ? Date.now() : 0,
312
+ coverUrl, originalCoverUrl: coverUrl, isFeatured: isFeatured ? "true" : "false",
313
+ status: "saved", savedMessage, savedId: id,
314
+ error: bodyTooLarge ? copyParam("ownerPostForm.bodyTooLarge", "The post body is too long to save, so it was left as it was. Your other changes were saved.") : "",
315
+ });
240
316
  try {
241
- // A replay worker starts without the loaded document. Entity upserts
242
- // replace whole rows, so retain the cached rich body before writing back.
243
- if (preserveBody && !richContent) {
317
+ // A replay worker starts without the loaded document. Entity upserts replace whole rows, so
318
+ // retain the cached rich body before writing back.
319
+ if (!bodyEdited && !kept) {
244
320
  const cached = await cacheGet(targetId);
245
- if (cached?.richContent) richContent = JSON.parse(String(cached.richContent));
321
+ if (cached?.richContent) kept = JSON.parse(String(cached.richContent));
246
322
  }
247
323
  // Blog cover images reference Media Manager file ids. The legacy editor
248
324
  // wrote coverImage.id; current Blog v3 expects media.wixMedia.image.id.
@@ -254,6 +330,8 @@ const svc = defineService<PostFormState, {
254
330
  draftPost.media = { wixMedia: { image: { id: result.file.id } }, displayed: true, custom: true };
255
331
  }
256
332
  }
333
+ // The draft PATCH has no field mask (`fieldsets` only shapes the response): leaving
334
+ // `richContent` out of `draftPost` is the whole omission.
257
335
  let id = targetId;
258
336
  if (editing) {
259
337
  await updateDraftPost(targetId, draftPost);
@@ -264,31 +342,23 @@ const svc = defineService<PostFormState, {
264
342
  if (id !== localId) await cacheDrop(localId);
265
343
  }
266
344
  await cachePut(localRow(id));
267
- return shape({
268
- ...s, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
269
- title, excerpt, body, richContent, coverUrl, originalCoverUrl: coverUrl, isFeatured: isFeatured ? "true" : "false",
270
- status: "saved", error: "",
271
- savedMessage: editing
272
- ? copyParam("ownerPostForm.updatedMessage", "Post updated.")
273
- : copyParam("ownerPostForm.createdMessage", "Draft created."),
274
- savedId: id,
275
- });
345
+ return savedState(id, editing ? copyParam("ownerPostForm.updatedMessage", "Post updated.") : copyParam("ownerPostForm.createdMessage", "Draft created."));
276
346
  } catch (e) {
277
347
  if (shouldQueueOwnerWrite(e)) {
278
348
  // Demo / web-preview (no owner bridge; the record came from demo sample
279
349
  // data, marked by demoNote): a queued write could never replay, so answer
280
350
  // with the demo save note instead of the real-offline write-behind.
281
351
  if (s.demoNote) return demoRefusal(s);
282
- // Real device offline: cache and queue the edit for reconnect.
352
+ // Real device offline: cache and queue the edit for reconnect. `preserveBody` stays
353
+ // "true" even for a body edit, so a bundle rolled back past this change keeps the stored
354
+ // body rather than flattening it to text. This bundle replays `richContentJson`.
283
355
  await cachePut(localRow(localId));
284
- await queueWrite("tier5.himiOwnerLitePostForm.submit", { id: targetId, title, excerpt, body, coverUrl, coverChanged: String(coverChanged), preserveBody: String(preserveBody), isFeatured: String(isFeatured), mode: editing ? "edit" : "create" });
285
- return shape({
286
- ...s, mode: "edit", id: localId, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
287
- title, excerpt, body, richContent, coverUrl, originalCoverUrl: coverUrl, isFeatured: isFeatured ? "true" : "false",
288
- status: "saved", error: "",
289
- savedMessage: copyParam("ownerPostForm.savedOffline", "Saved offline — will sync when you reconnect."),
290
- savedId: localId,
356
+ await queueWrite("tier5.himiOwnerLitePostForm.submit", {
357
+ id: targetId, title, excerpt, coverUrl, coverChanged: String(coverChanged), preserveBody: "true",
358
+ body: postBodyText(kept), ...(bodyEdited ? { richContentJson: bodyRaw } : {}),
359
+ isFeatured: String(isFeatured), mode: editing ? "edit" : "create",
291
360
  });
361
+ return savedState(localId, copyParam("ownerPostForm.savedOffline", "Saved offline — will sync when you reconnect."));
292
362
  }
293
363
  return shape({ ...s, status: "error", error: ownerLoadError(e), savedMessage: "" });
294
364
  }
@@ -296,4 +366,20 @@ const svc = defineService<PostFormState, {
296
366
  },
297
367
  });
298
368
 
369
+ // [rico] #3371 — adopter contract (#3368's review): the editor seeds ONCE, from the first
370
+ // document `bodyJson` holds. A cold boot hydrates this service from its last persisted snapshot, so
371
+ // that first value would be the document as it was when this screen last ran — and a Save after
372
+ // typing would write it over whatever the post holds now. Blank the hydrated seed before anything
373
+ // renders: the editor stays provisional (writes nothing) until `load` publishes the fresh document.
374
+ // In place, at boot, because no action has run yet; the state object is the runtime's own merge.
375
+ {
376
+ const booted = (svc as { getState?: () => Partial<PostFormState> | undefined }).getState?.();
377
+ if (booted && typeof booted === "object") {
378
+ if (booted.bodyJson) booted.bodyJson = "";
379
+ // Nor does it carry this run's UI over: the editor waits for this run's load, the sheet is shut.
380
+ booted.bodyPending = startMode === "edit";
381
+ booted.detailsOpen = false;
382
+ }
383
+ }
384
+
299
385
  export { svc };