@wix/himalaya-cli 0.1173.0 → 0.1175.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 (79) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/extensions.d.ts +13 -3
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/products-v3.d.ts +13 -1
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores.mjs +10 -3
  4. package/dist/cli.mjs +66 -23
  5. package/dist/preview-assets.generated.bin +0 -0
  6. package/guides/sdk-reference.json +15 -3
  7. package/package.json +2 -2
  8. package/templates/beauty-salon/dev/index.ts +63 -41
  9. package/templates/beauty-salon-owner/dev/index.ts +172 -6
  10. package/templates/beauty-salon-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  11. package/templates/beauty-salon-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  12. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  13. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  14. package/templates/branded-lite/dev/index.ts +71 -47
  15. package/templates/community-club/dev/index.ts +63 -41
  16. package/templates/community-club-owner/dev/index.ts +257 -8
  17. package/templates/community-club-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  18. package/templates/community-club-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  19. package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  20. package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  21. package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  22. package/templates/events-meetups/dev/index.ts +63 -41
  23. package/templates/events-meetups-owner/dev/index.ts +87 -4
  24. package/templates/events-meetups-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  25. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  26. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  27. package/templates/gym-fitness/dev/index.ts +63 -41
  28. package/templates/gym-fitness-owner/dev/index.ts +257 -8
  29. package/templates/gym-fitness-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  30. package/templates/gym-fitness-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  31. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  32. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  33. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  34. package/templates/nonprofit-cause/dev/index.ts +63 -41
  35. package/templates/nonprofit-cause-owner/dev/index.ts +87 -4
  36. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  37. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  38. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  39. package/templates/owner-lite/dev/index.ts +512 -17
  40. package/templates/owner-lite/tier5-src/_shared/owner-editor-value.ts +71 -5
  41. package/templates/owner-lite/tier5-src/_shared/owner-rich-guard.ts +1 -9
  42. package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  43. package/templates/owner-lite/tier5-src/himi-owner-lite-faq-question-form/index.ts +77 -10
  44. package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  45. package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  46. package/templates/podcast-creator/dev/index.ts +63 -41
  47. package/templates/podcast-creator-owner/dev/index.ts +257 -8
  48. package/templates/podcast-creator-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  49. package/templates/podcast-creator-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  50. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  51. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  52. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  53. package/templates/portfolio-creative/dev/index.ts +63 -41
  54. package/templates/portfolio-creative-owner/dev/index.ts +172 -6
  55. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  56. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  57. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  58. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  59. package/templates/restaurant/dev/index.ts +71 -47
  60. package/templates/restaurant-owner/dev/index.ts +257 -8
  61. package/templates/restaurant-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  62. package/templates/restaurant-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  63. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  64. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  65. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  66. package/templates/retail-store/dev/index.ts +63 -41
  67. package/templates/retail-store-owner/dev/index.ts +172 -6
  68. package/templates/retail-store-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  69. package/templates/retail-store-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  70. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  71. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  72. package/templates/yoga-studio/dev/index.ts +63 -41
  73. package/templates/yoga-studio-owner/dev/index.ts +87 -4
  74. package/templates/yoga-studio-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  75. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  76. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  77. package/templates/events-meetups-owner/tier5-src/_shared/owner-rich-guard.ts +0 -35
  78. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-rich-guard.ts +0 -35
  79. package/templates/yoga-studio-owner/tier5-src/_shared/owner-rich-guard.ts +0 -35
@@ -4,8 +4,7 @@
4
4
  // screen (himiOwnerLitePost.publish). Bound by buildForm ($service:himiOwnerLitePostForm.*).
5
5
 
6
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";
7
+ import { editorSubmitValue, queuedDocument, editorChanged, resetHydratedEditor } from "../_shared/owner-editor-value.js";
9
8
  import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
10
9
  import { wixCall } from "@himalaya/wix-site-media";
11
10
  import { wixMediaUrl, WIX_DETAIL_IMAGE } from "../_shared/himi-store.js";
@@ -43,15 +42,10 @@ interface PostFormState {
43
42
  bodyJson: string;
44
43
  /** The "Post details" sheet (excerpt + cover) is open. */
45
44
  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. */
45
+ /** The editor gate (`EditorGate` in _shared/owner-editor-value.ts): the editor's on_change fired since
46
+ * the body was loaded or saved, and (edit mode) the editor is hidden until this run's load lands. */
49
47
  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
48
  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;
55
49
  coverUrl: string;
56
50
  originalCoverUrl: string;
57
51
  coverPreview: string;
@@ -104,9 +98,7 @@ function boolString(value: unknown, fallback: string): string {
104
98
  return fallback;
105
99
  }
106
100
  /** 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;
101
+ const BODY_LOADED = { bodyPending: false, bodyDirty: false, detailsOpen: false } as const;
110
102
  const valueOf = (arg: unknown): string =>
111
103
  String(typeof arg === "string" ? arg : (arg as { value?: unknown; text?: unknown } | undefined)?.value ?? (arg as { text?: unknown } | undefined)?.text ?? "");
112
104
  const docJson = (doc: unknown): string => (doc ? JSON.stringify(doc) : "");
@@ -128,7 +120,6 @@ function shape(s: Partial<PostFormState>): PostFormState {
128
120
  detailsOpen: s.detailsOpen ?? false,
129
121
  bodyDirty: s.bodyDirty ?? false,
130
122
  bodyPending: s.bodyPending ?? false,
131
- bodySavedAt: s.bodySavedAt ?? 0,
132
123
  coverUrl: s.coverUrl ?? "",
133
124
  originalCoverUrl: s.originalCoverUrl ?? "",
134
125
  coverPreview: wixMediaUrl(s.coverUrl ?? "", WIX_DETAIL_IMAGE),
@@ -244,13 +235,8 @@ const svc = defineService<PostFormState, {
244
235
  setExcerpt: (s, arg) => shape({ ...s, excerpt: valueOf(arg), error: "" }),
245
236
  detailsOpen: (s) => shape({ ...s, detailsOpen: true }),
246
237
  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 }) });
253
- },
238
+ // The editor's `on_change` carries `args.dirty` only; the document stays in the form bag.
239
+ bodyChanged: (s) => shape({ ...s, bodyDirty: true, ...editorChanged(s) }),
254
240
  submit: async (s, values) => {
255
241
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
256
242
  const v = values ?? {};
@@ -277,14 +263,15 @@ const svc = defineService<PostFormState, {
277
263
  let bodyRaw = "";
278
264
  let bodyTooLarge = false;
279
265
  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); }
266
+ const queued = queuedDocument(v.richContentJson);
267
+ // A queued body that no longer reads (or is past the limits) writes NOTHING, and says so.
268
+ if (queued.kind === "invalid" || queued.kind === "too_large") return shape({ ...s, status: "error", error: copyParam("ownerPostForm.queuedBodyError", "The post body saved offline could not be read, so nothing was written. Edit it again."), savedMessage: "" });
269
+ if (queued.kind === "doc") { richContent = queued.doc as unknown as WixRichContent; bodyRaw = queued.json; }
282
270
  else if (v.preserveBody === "false") { richContent = plainPostBody(String(v.body ?? "")); bodyRaw = docJson(richContent); }
283
271
  } 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);
272
+ // Only an edit the editor reported counts (EditorGate): before load lands the editor is hidden,
273
+ // and an untouched bag holds the engine's re-serialization of the seed.
274
+ const ev = editorSubmitValue(v.body, s.bodyJson, { pending: s.bodyPending, dirty: s.bodyDirty });
288
275
  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
276
  if (ev.kind === "doc") { richContent = ev.doc as unknown as WixRichContent; bodyRaw = ev.json; }
290
277
  // A body past the rich-content limits is not written (every viewer would show its
@@ -308,7 +295,7 @@ const svc = defineService<PostFormState, {
308
295
  const savedState = (id: string, savedMessage: string) => shape({
309
296
  ...s, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
310
297
  title, excerpt, body: postBodyText(kept), richContent: kept, bodyJson: bodyEdited ? bodyRaw : s.bodyJson,
311
- bodyDirty: bodyEdited ? false : s.bodyDirty, bodySavedAt: bodyEdited ? Date.now() : 0,
298
+ bodyDirty: bodyEdited ? false : s.bodyDirty,
312
299
  coverUrl, originalCoverUrl: coverUrl, isFeatured: isFeatured ? "true" : "false",
313
300
  status: "saved", savedMessage, savedId: id,
314
301
  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.") : "",
@@ -366,20 +353,8 @@ const svc = defineService<PostFormState, {
366
353
  },
367
354
  });
368
355
 
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
- }
356
+ // [rico] #3371 — adopter contract: the editor seeds once, so a cold boot must not seed it with the last
357
+ // run's document. Nor does it carry this run's UI over: the details sheet is shut.
358
+ resetHydratedEditor(svc, { json: "bodyJson", pending: "bodyPending", dirty: "bodyDirty" }, startMode === "edit", { detailsOpen: false });
384
359
 
385
360
  export { svc };
@@ -1,35 +0,0 @@
1
- // May a plain owner form round-trip this body? The Ricos and HTML checks are @himalaya/wix-rich-content's;
2
- // this file adds the read-only previews the forms show for a locked body, and the FAQ's legacy DraftJS answer.
3
- // Inside the platform source this import is a relative path, so node test runs resolve it; an app's copy
4
- // imports "@himalaya/wix-rich-content".
5
- import { htmlIsTrivial, isPlainRicos } from "@himalaya/wix-rich-content";
6
-
7
- export { htmlIsTrivial };
8
-
9
- const ENTITIES: Record<string, string> = { "&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"", "&#39;": "'" };
10
-
11
- /** Read-only preview text for an HTML body: block closes become lines, tags are stripped (a bare
12
- * `<` in prose is kept), the basic entities decode, and blank lines drop. */
13
- export function htmlPreviewText(html: string | null | undefined): string {
14
- return (html ?? "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li|div|blockquote|pre|tr)>/gi, "\n")
15
- .replace(/<\/?[a-z!?][^>]*>/gi, "").replace(/&(nbsp|amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m] ?? m)
16
- .split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
17
- }
18
-
19
- /** True when a plain text field can round-trip this Ricos document: no document styling, and
20
- * only paragraphs of undecorated text whose paragraph data is the wire default (Events v3 sends
21
- * `{ textStyle: { textAlignment: "AUTO" }, indentation: 0 }` on every paragraph). Anything else —
22
- * a heading, list, link, bold run, media node, alignment or indent — is rich, and so is a missing
23
- * document (callers decide what an absent body means). */
24
- export const ricosIsPlain = (doc: { nodes?: unknown; documentStyle?: unknown } | null | undefined): boolean =>
25
- !!doc && isPlainRicos(doc);
26
-
27
- /** Preview text for a legacy DraftJS document: its `blocks[].text` joined by a blank line, or ""
28
- * when the payload is not DraftJS JSON (never a throw). */
29
- export function draftjsText(raw: string | null | undefined): string {
30
- try {
31
- const doc = JSON.parse(raw ?? "") as { blocks?: Array<{ text?: unknown }> } | null;
32
- const blocks = Array.isArray(doc?.blocks) ? doc.blocks : [];
33
- return blocks.map((b) => (typeof b?.text === "string" ? b.text : "")).join("\n\n").trim();
34
- } catch { return ""; }
35
- }
@@ -1,35 +0,0 @@
1
- // May a plain owner form round-trip this body? The Ricos and HTML checks are @himalaya/wix-rich-content's;
2
- // this file adds the read-only previews the forms show for a locked body, and the FAQ's legacy DraftJS answer.
3
- // Inside the platform source this import is a relative path, so node test runs resolve it; an app's copy
4
- // imports "@himalaya/wix-rich-content".
5
- import { htmlIsTrivial, isPlainRicos } from "@himalaya/wix-rich-content";
6
-
7
- export { htmlIsTrivial };
8
-
9
- const ENTITIES: Record<string, string> = { "&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"", "&#39;": "'" };
10
-
11
- /** Read-only preview text for an HTML body: block closes become lines, tags are stripped (a bare
12
- * `<` in prose is kept), the basic entities decode, and blank lines drop. */
13
- export function htmlPreviewText(html: string | null | undefined): string {
14
- return (html ?? "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li|div|blockquote|pre|tr)>/gi, "\n")
15
- .replace(/<\/?[a-z!?][^>]*>/gi, "").replace(/&(nbsp|amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m] ?? m)
16
- .split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
17
- }
18
-
19
- /** True when a plain text field can round-trip this Ricos document: no document styling, and
20
- * only paragraphs of undecorated text whose paragraph data is the wire default (Events v3 sends
21
- * `{ textStyle: { textAlignment: "AUTO" }, indentation: 0 }` on every paragraph). Anything else —
22
- * a heading, list, link, bold run, media node, alignment or indent — is rich, and so is a missing
23
- * document (callers decide what an absent body means). */
24
- export const ricosIsPlain = (doc: { nodes?: unknown; documentStyle?: unknown } | null | undefined): boolean =>
25
- !!doc && isPlainRicos(doc);
26
-
27
- /** Preview text for a legacy DraftJS document: its `blocks[].text` joined by a blank line, or ""
28
- * when the payload is not DraftJS JSON (never a throw). */
29
- export function draftjsText(raw: string | null | undefined): string {
30
- try {
31
- const doc = JSON.parse(raw ?? "") as { blocks?: Array<{ text?: unknown }> } | null;
32
- const blocks = Array.isArray(doc?.blocks) ? doc.blocks : [];
33
- return blocks.map((b) => (typeof b?.text === "string" ? b.text : "")).join("\n\n").trim();
34
- } catch { return ""; }
35
- }
@@ -1,35 +0,0 @@
1
- // May a plain owner form round-trip this body? The Ricos and HTML checks are @himalaya/wix-rich-content's;
2
- // this file adds the read-only previews the forms show for a locked body, and the FAQ's legacy DraftJS answer.
3
- // Inside the platform source this import is a relative path, so node test runs resolve it; an app's copy
4
- // imports "@himalaya/wix-rich-content".
5
- import { htmlIsTrivial, isPlainRicos } from "@himalaya/wix-rich-content";
6
-
7
- export { htmlIsTrivial };
8
-
9
- const ENTITIES: Record<string, string> = { "&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"", "&#39;": "'" };
10
-
11
- /** Read-only preview text for an HTML body: block closes become lines, tags are stripped (a bare
12
- * `<` in prose is kept), the basic entities decode, and blank lines drop. */
13
- export function htmlPreviewText(html: string | null | undefined): string {
14
- return (html ?? "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li|div|blockquote|pre|tr)>/gi, "\n")
15
- .replace(/<\/?[a-z!?][^>]*>/gi, "").replace(/&(nbsp|amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m] ?? m)
16
- .split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
17
- }
18
-
19
- /** True when a plain text field can round-trip this Ricos document: no document styling, and
20
- * only paragraphs of undecorated text whose paragraph data is the wire default (Events v3 sends
21
- * `{ textStyle: { textAlignment: "AUTO" }, indentation: 0 }` on every paragraph). Anything else —
22
- * a heading, list, link, bold run, media node, alignment or indent — is rich, and so is a missing
23
- * document (callers decide what an absent body means). */
24
- export const ricosIsPlain = (doc: { nodes?: unknown; documentStyle?: unknown } | null | undefined): boolean =>
25
- !!doc && isPlainRicos(doc);
26
-
27
- /** Preview text for a legacy DraftJS document: its `blocks[].text` joined by a blank line, or ""
28
- * when the payload is not DraftJS JSON (never a throw). */
29
- export function draftjsText(raw: string | null | undefined): string {
30
- try {
31
- const doc = JSON.parse(raw ?? "") as { blocks?: Array<{ text?: unknown }> } | null;
32
- const blocks = Array.isArray(doc?.blocks) ? doc.blocks : [];
33
- return blocks.map((b) => (typeof b?.text === "string" ? b.text : "")).join("\n\n").trim();
34
- } catch { return ""; }
35
- }