@wix/himalaya-cli 0.1105.0 → 0.1106.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 (91) hide show
  1. package/dist/cli.mjs +0 -1
  2. package/fixtures/wix-canned.json +146 -0
  3. package/package.json +2 -2
  4. package/templates/beauty-salon/dev/index.ts +22 -79
  5. package/templates/beauty-salon/tier5-src/_shared/rich-body.ts +28 -0
  6. package/templates/beauty-salon/tier5-src/_shared/rich-content-link.ts +34 -0
  7. package/templates/beauty-salon/tier5-src/himi-blog-post/index.ts +26 -82
  8. package/templates/beauty-salon-owner/dev/index.ts +44 -1
  9. package/templates/beauty-salon-owner/tier5-src/_shared/rich-body.ts +28 -0
  10. package/templates/beauty-salon-owner/tier5-src/_shared/rich-content-link.ts +34 -0
  11. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post/index.ts +49 -2
  12. package/templates/branded-lite/dev/index.ts +22 -79
  13. package/templates/branded-lite/tier5-src/_shared/rich-body.ts +28 -0
  14. package/templates/branded-lite/tier5-src/_shared/rich-content-link.ts +34 -0
  15. package/templates/branded-lite/tier5-src/himi-blog-post/index.ts +26 -82
  16. package/templates/community-club/dev/index.ts +22 -79
  17. package/templates/community-club/tier5-src/_shared/rich-body.ts +28 -0
  18. package/templates/community-club/tier5-src/_shared/rich-content-link.ts +34 -0
  19. package/templates/community-club/tier5-src/himi-blog-post/index.ts +26 -82
  20. package/templates/community-club-owner/dev/index.ts +44 -1
  21. package/templates/community-club-owner/tier5-src/_shared/rich-body.ts +28 -0
  22. package/templates/community-club-owner/tier5-src/_shared/rich-content-link.ts +34 -0
  23. package/templates/community-club-owner/tier5-src/himi-owner-lite-post/index.ts +49 -2
  24. package/templates/events-meetups/dev/index.ts +22 -79
  25. package/templates/events-meetups/tier5-src/_shared/rich-body.ts +28 -0
  26. package/templates/events-meetups/tier5-src/_shared/rich-content-link.ts +34 -0
  27. package/templates/events-meetups/tier5-src/himi-blog-post/index.ts +26 -82
  28. package/templates/events-meetups-owner/dev/index.ts +44 -1
  29. package/templates/events-meetups-owner/tier5-src/_shared/rich-body.ts +28 -0
  30. package/templates/events-meetups-owner/tier5-src/_shared/rich-content-link.ts +34 -0
  31. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post/index.ts +49 -2
  32. package/templates/gym-fitness/dev/index.ts +22 -79
  33. package/templates/gym-fitness/tier5-src/_shared/rich-body.ts +28 -0
  34. package/templates/gym-fitness/tier5-src/_shared/rich-content-link.ts +34 -0
  35. package/templates/gym-fitness/tier5-src/himi-blog-post/index.ts +26 -82
  36. package/templates/gym-fitness-owner/dev/index.ts +44 -1
  37. package/templates/gym-fitness-owner/tier5-src/_shared/rich-body.ts +28 -0
  38. package/templates/gym-fitness-owner/tier5-src/_shared/rich-content-link.ts +34 -0
  39. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post/index.ts +49 -2
  40. package/templates/nonprofit-cause/dev/index.ts +22 -79
  41. package/templates/nonprofit-cause/tier5-src/_shared/rich-body.ts +28 -0
  42. package/templates/nonprofit-cause/tier5-src/_shared/rich-content-link.ts +34 -0
  43. package/templates/nonprofit-cause/tier5-src/himi-blog-post/index.ts +26 -82
  44. package/templates/nonprofit-cause-owner/dev/index.ts +44 -1
  45. package/templates/nonprofit-cause-owner/tier5-src/_shared/rich-body.ts +28 -0
  46. package/templates/nonprofit-cause-owner/tier5-src/_shared/rich-content-link.ts +34 -0
  47. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post/index.ts +49 -2
  48. package/templates/owner-lite/dev/index.ts +44 -1
  49. package/templates/owner-lite/tier5-src/_shared/rich-body.ts +28 -0
  50. package/templates/owner-lite/tier5-src/_shared/rich-content-link.ts +34 -0
  51. package/templates/owner-lite/tier5-src/himi-owner-lite-post/index.ts +49 -2
  52. package/templates/podcast-creator/dev/index.ts +22 -79
  53. package/templates/podcast-creator/tier5-src/_shared/rich-body.ts +28 -0
  54. package/templates/podcast-creator/tier5-src/_shared/rich-content-link.ts +34 -0
  55. package/templates/podcast-creator/tier5-src/himi-blog-post/index.ts +26 -82
  56. package/templates/podcast-creator-owner/dev/index.ts +44 -1
  57. package/templates/podcast-creator-owner/tier5-src/_shared/rich-body.ts +28 -0
  58. package/templates/podcast-creator-owner/tier5-src/_shared/rich-content-link.ts +34 -0
  59. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post/index.ts +49 -2
  60. package/templates/portfolio-creative/dev/index.ts +22 -79
  61. package/templates/portfolio-creative/tier5-src/_shared/rich-body.ts +28 -0
  62. package/templates/portfolio-creative/tier5-src/_shared/rich-content-link.ts +34 -0
  63. package/templates/portfolio-creative/tier5-src/himi-blog-post/index.ts +26 -82
  64. package/templates/portfolio-creative-owner/dev/index.ts +44 -1
  65. package/templates/portfolio-creative-owner/tier5-src/_shared/rich-body.ts +28 -0
  66. package/templates/portfolio-creative-owner/tier5-src/_shared/rich-content-link.ts +34 -0
  67. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post/index.ts +49 -2
  68. package/templates/restaurant/dev/index.ts +22 -79
  69. package/templates/restaurant/tier5-src/_shared/rich-body.ts +28 -0
  70. package/templates/restaurant/tier5-src/_shared/rich-content-link.ts +34 -0
  71. package/templates/restaurant/tier5-src/himi-blog-post/index.ts +26 -82
  72. package/templates/restaurant-owner/dev/index.ts +44 -1
  73. package/templates/restaurant-owner/tier5-src/_shared/rich-body.ts +28 -0
  74. package/templates/restaurant-owner/tier5-src/_shared/rich-content-link.ts +34 -0
  75. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post/index.ts +49 -2
  76. package/templates/retail-store/dev/index.ts +22 -79
  77. package/templates/retail-store/tier5-src/_shared/rich-body.ts +28 -0
  78. package/templates/retail-store/tier5-src/_shared/rich-content-link.ts +34 -0
  79. package/templates/retail-store/tier5-src/himi-blog-post/index.ts +26 -82
  80. package/templates/retail-store-owner/dev/index.ts +44 -1
  81. package/templates/retail-store-owner/tier5-src/_shared/rich-body.ts +28 -0
  82. package/templates/retail-store-owner/tier5-src/_shared/rich-content-link.ts +34 -0
  83. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post/index.ts +49 -2
  84. package/templates/yoga-studio/dev/index.ts +22 -79
  85. package/templates/yoga-studio/tier5-src/_shared/rich-body.ts +28 -0
  86. package/templates/yoga-studio/tier5-src/_shared/rich-content-link.ts +34 -0
  87. package/templates/yoga-studio/tier5-src/himi-blog-post/index.ts +26 -82
  88. package/templates/yoga-studio-owner/dev/index.ts +44 -1
  89. package/templates/yoga-studio-owner/tier5-src/_shared/rich-body.ts +28 -0
  90. package/templates/yoga-studio-owner/tier5-src/_shared/rich-content-link.ts +34 -0
  91. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post/index.ts +49 -2
@@ -33627,103 +33627,45 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
33627
33627
  }
33628
33628
  },
33629
33629
  {
33630
- "id": "himi_blog_post.blocks",
33630
+ "id": "post_body",
33631
33631
  "body": {
33632
33632
  "package": "himalaya",
33633
- "type": "Grid",
33633
+ "type": "RichContent",
33634
33634
  "props": {
33635
- "columns": 1,
33636
- "spacing": 12,
33637
- "itemsPath": "$service:himiBlogPost.blocks",
33638
- "items": [
33639
- {
33640
- "id": "",
33641
- "body": {
33642
- "package": "himalaya",
33643
- "type": "VStack",
33644
- "props": {
33645
- "spacing": 0,
33646
- "alignment": "leading",
33647
- "children": [
33648
- {
33649
- "id": "himi_blog_post.block_heading",
33650
- "body": {
33651
- "package": "himalaya",
33652
- "type": "Text",
33653
- "props": {
33654
- "text": "$item.text",
33655
- "style": "title3",
33656
- "collapseWhenEmpty": true
33657
- }
33658
- },
33659
- "modifiers": {
33660
- "padding": {
33661
- "top": 8,
33662
- "bottom": 2
33663
- },
33664
- "hiddenPath": "$item.notHeading"
33665
- }
33666
- },
33667
- {
33668
- "id": "",
33669
- "body": {
33670
- "package": "himalaya",
33671
- "type": "Text",
33672
- "props": {
33673
- "text": "$item.text",
33674
- "style": "body",
33675
- "color": "textPrimary",
33676
- "collapseWhenEmpty": true
33677
- }
33678
- },
33679
- "modifiers": {
33680
- "hiddenPath": "$item.notText"
33681
- }
33682
- },
33683
- {
33684
- "id": "",
33685
- "body": {
33686
- "package": "himalaya",
33687
- "type": "Image",
33688
- "props": {
33689
- "url": "$item.image",
33690
- "fillWidth": true,
33691
- "height": 220,
33692
- "contentMode": "fill"
33693
- }
33694
- },
33695
- "modifiers": {
33696
- "accessibilityRole": "decorative",
33697
- "cornerRadius": 8,
33698
- "fillMaxWidth": true,
33699
- "hiddenPath": "$item.notImage"
33700
- }
33701
- }
33702
- ]
33703
- }
33635
+ "documentJsonPath": "$service:himiBlogPost.bodyJson",
33636
+ "onLink": {
33637
+ "actionId": "tier5.himiBlogPost.openLink"
33638
+ },
33639
+ "fallback": {
33640
+ "id": "",
33641
+ "body": {
33642
+ "package": "himalaya",
33643
+ "type": "Text",
33644
+ "props": {
33645
+ "text": "$service:himiBlogPost.bodyText",
33646
+ "style": "body",
33647
+ "color": "textPrimary",
33648
+ "collapseWhenEmpty": true
33704
33649
  }
33705
33650
  }
33706
- ]
33651
+ }
33707
33652
  }
33708
- },
33709
- "modifiers": {
33710
- "hiddenPath": "$service:himiBlogPost.noBlocks"
33711
33653
  }
33712
33654
  },
33713
33655
  {
33714
- "id": "",
33656
+ "id": "post_body_text",
33715
33657
  "body": {
33716
33658
  "package": "himalaya",
33717
33659
  "type": "Text",
33718
33660
  "props": {
33719
- "text": "$service:himiBlogPost.body",
33661
+ "text": "$service:himiBlogPost.bodyText",
33720
33662
  "style": "body",
33721
33663
  "color": "textPrimary",
33722
33664
  "collapseWhenEmpty": true
33723
33665
  }
33724
33666
  },
33725
33667
  "modifiers": {
33726
- "hiddenPath": "$service:himiBlogPost.hasBlocks"
33668
+ "hiddenPath": "$service:himiBlogPost.hasBodyJson"
33727
33669
  }
33728
33670
  },
33729
33671
  {
@@ -34124,7 +34066,8 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
34124
34066
  "declaredCapabilities": [
34125
34067
  "wix_api",
34126
34068
  "share",
34127
- "local_database"
34069
+ "local_database",
34070
+ "open_url"
34128
34071
  ],
34129
34072
  "minBinaryVersion": "0.1.0",
34130
34073
  "bundleVersion": "0.0.1"
@@ -0,0 +1,28 @@
1
+ // [rico] #3370 — an entity's Ricos body, prepared for a RichContent component and its plain-text fallback.
2
+ // One rule for every worker that renders a body (the blog post and the owner post detail today; #3372's
3
+ // verticals next), so they cannot drift apart.
4
+ // Inside the platform source this import is a relative path, so node test runs resolve it; an app's copy
5
+ // imports "@himalaya/wix-rich-content".
6
+ import { encodeRichContent, parseRicosJson, ricosToPlainText } from "@himalaya/wix-rich-content";
7
+
8
+ /** What a body-rendering worker publishes: `<name>Json` for `documentJsonPath`, and the plain text. */
9
+ export interface RichBody {
10
+ /** The document as ONE JSON string (media sized, disallowed links dropped); "" when there is no document
11
+ * or the encoder rejected it. RichContent draws nothing for "", so a screen gates its plain text on it. */
12
+ bodyJson: string;
13
+ /** The plain text: the document's own text when it has any, else `fallback`. */
14
+ bodyText: string;
15
+ }
16
+
17
+ /** Prepares `richContent` (a Ricos document, as a Wix API returns it, or its JSON string). No document, or one
18
+ * that is not a document, gives `{ bodyJson: "", bodyText: fallback }`. A document past the viewer's limits
19
+ * gives bodyJson "" and its plain text — except one rejected as too deep, which is not walked again (the
20
+ * plain-text walk has no depth limit), so `fallback` stands in for it. */
21
+ export function richBody(richContent: unknown, fallback = ""): RichBody {
22
+ const doc = parseRicosJson(richContent);
23
+ if (!doc) return { bodyJson: "", bodyText: fallback };
24
+ // 1400 px — the detail-image budget, which is also the encoder's maximum.
25
+ const { json, rejected } = encodeRichContent(doc, { imageWidth: 1400 });
26
+ const text = rejected === "too_deep" ? "" : ricosToPlainText(doc);
27
+ return { bodyJson: json, bodyText: text || fallback };
28
+ }
@@ -0,0 +1,34 @@
1
+ // [rico] #3370 — a RichContent `on_link` tap reaches the worker as `{ url }` (the renderers merge the tapped
2
+ // link into the action's args). Only http(s) with a host, mailto and tel ever reach the OS; anything
3
+ // else is dropped here, before any dispatch. The renderers already refuse other schemes, but the action
4
+ // arrives here as plain data, so the worker checks it again rather than trusting whoever sent it.
5
+ import { open, defaultMode } from "@himalaya/open-url";
6
+
7
+ /** The viewer's link rule, verbatim (`LINK` in @himalaya/wix-rich-content's display.ts, which the package does
8
+ * not export); a parity test pins this copy to the original. */
9
+ const LINK = /^(https?:\/\/[^\s/?#]\S*|mailto:\S+|tel:\S+)$/i;
10
+ /** ASCII whitespace only, as the viewer trims a link before applying the rule. */
11
+ const trimAscii = (s: string): string => s.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, "");
12
+
13
+ /** The url of a link tap (`{ url }`, `{ args: { url } }` or a bare string), or "" when it is missing or
14
+ * not an http(s), mailto or tel link. */
15
+ export function richLinkUrl(arg: unknown): string {
16
+ const a = arg as { url?: unknown; args?: { url?: unknown } } | null | undefined;
17
+ const raw = typeof arg === "string" ? arg
18
+ : typeof a?.url === "string" ? a.url
19
+ : typeof a?.args?.url === "string" ? a.args.url : "";
20
+ const url = trimAscii(raw);
21
+ return LINK.test(url) ? url : "";
22
+ }
23
+
24
+ /** Opens a link tap through `capability.open_url` (http(s) in the in-app browser, mailto/tel in the system
25
+ * handler). False when the link is blocked, nothing took it, or the host threw. */
26
+ export async function openRichLink(arg: unknown): Promise<boolean> {
27
+ const url = richLinkUrl(arg);
28
+ if (!url) return false;
29
+ try {
30
+ return (await open(url, defaultMode(url))).opened;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
@@ -3,11 +3,12 @@ import { defineService } from "@himalaya/state";
3
3
  import { share as shareSheet } from "@himalaya/share";
4
4
  import { getPost, likePost, unlikePost, type WixPost } from "@himalaya/wix-blog";
5
5
  import { comments, BLOG_COMMENTS_APP_ID, blogCommentRow, listBlogComments, type WixBlogCommentRow } from "@himalaya/wix-comments";
6
- import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
7
6
  import { get, type DbEntity } from "@himalaya/local-db";
8
7
  import { isSavedId, toggleSavedId } from "../_shared/himi-blog-saved";
9
8
  import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
10
9
  import { formatDateMedium, formatMonthDay } from "../_shared/engine-intl.js";
10
+ import { openRichLink } from "../_shared/rich-content-link.js";
11
+ import { richBody } from "../_shared/rich-body.js";
11
12
 
12
13
  // Offline-first (data-offline): the post body is a public read-through — when the
13
14
  // live fetch fails (offline) fall back to the last-synced row the feed cached under
@@ -26,23 +27,6 @@ interface CommentRow {
26
27
  meta: string;
27
28
  }
28
29
 
29
- /** One rich-content block row for the mixed-row body template: a text
30
- * paragraph, a heading, or an inline image — exactly one variant renders per
31
- * row (per-$item hiddenWhen, the himi_menu mixed-row contract). Rendering the
32
- * ricos `richContent` this way keeps INLINE IMAGES (previously lost by the
33
- * plainContent flattening). */
34
- interface BlockRow {
35
- id: string;
36
- isText: boolean;
37
- notText: boolean;
38
- isHeading: boolean;
39
- notHeading: boolean;
40
- isImage: boolean;
41
- notImage: boolean;
42
- text: string;
43
- image: string;
44
- }
45
-
46
30
  interface S {
47
31
  status: "loading" | "ready" | "error";
48
32
  id: string;
@@ -54,12 +38,13 @@ interface S {
54
38
  /** "N likes · M comments" from post.metrics — collapses at zero/absent. */
55
39
  countsLine: string;
56
40
  excerpt: string;
57
- body: string;
58
- /** Rich-content block rows (paragraphs / headings / inline images). When
59
- * present the screen renders these INSTEAD of the flattened `body`. */
60
- blocks: BlockRow[];
61
- hasBlocks: boolean;
62
- noBlocks: boolean;
41
+ /** [rico] #3370 — the body as ONE Ricos document JSON string for RichContent: the encoder has
42
+ * sized its media and dropped disallowed links. "" when the post has none, or the encoder rejected it. */
43
+ bodyJson: string;
44
+ /** bodyJson !== "" — gates the plain text, because RichContent draws nothing for "". */
45
+ hasBodyJson: boolean;
46
+ /** Plain text of the body: RichContent's `fallback`, and what shows when there is no document. */
47
+ bodyText: string;
63
48
  /** Share payload — the post's public URL when the payload carries one. */
64
49
  shareUrl: string;
65
50
  cover: string;
@@ -101,55 +86,6 @@ type PostWithExtras = WixPost & {
101
86
  author?: { name?: string; nickname?: string } | string;
102
87
  };
103
88
 
104
- /** One ricos node's plain text (joins the leaf textData runs). */
105
- function ricosText(node: unknown): string {
106
- const n = node as { textData?: { text?: string }; nodes?: unknown[] } | undefined;
107
- const own = typeof n?.textData?.text === "string" ? n.textData.text : "";
108
- const nested = Array.isArray(n?.nodes) ? n.nodes.map(ricosText).join("") : "";
109
- return own + nested;
110
- }
111
-
112
- /** A ricos IMAGE node's URL — direct `src.url` or a wixstatic media id. */
113
- function ricosImageUrl(node: unknown): string {
114
- const src = (node as { imageData?: { image?: { src?: { url?: string; id?: string } } } } | undefined)
115
- ?.imageData?.image?.src;
116
- return wixMediaUrl(src?.url ?? src?.id, WIX_DETAIL_IMAGE);
117
- }
118
-
119
- /** ricos richContent.nodes → mixed block rows (paragraphs, headings, inline
120
- * images). Anything unrecognized flattens to its text (or is dropped). */
121
- export function richContentBlocks(richContent: unknown): BlockRow[] {
122
- const nodes = (richContent as { nodes?: unknown[] } | undefined)?.nodes;
123
- if (!Array.isArray(nodes)) return [];
124
- const rows: BlockRow[] = [];
125
- const push = (kind: "text" | "heading" | "image", text: string, image: string) => {
126
- rows.push({
127
- id: `block_${rows.length}`,
128
- isText: kind === "text",
129
- notText: kind !== "text",
130
- isHeading: kind === "heading",
131
- notHeading: kind !== "heading",
132
- isImage: kind === "image",
133
- notImage: kind !== "image",
134
- text,
135
- image,
136
- });
137
- };
138
- for (const node of nodes) {
139
- const type = String((node as { type?: unknown } | undefined)?.type ?? "").toUpperCase();
140
- if (type === "IMAGE") {
141
- const url = ricosImageUrl(node);
142
- if (url) push("image", "", url);
143
- continue;
144
- }
145
- const text = ricosText(node).trim();
146
- if (!text) continue;
147
- if (type.startsWith("HEADING")) push("heading", text, "");
148
- else push("text", text, "");
149
- }
150
- return rows;
151
- }
152
-
153
89
  /** "By <name>" from the payload's writer-ish fields ("" when absent). */
154
90
  function bylineOf(post: PostWithExtras): string {
155
91
  const author = typeof post.author === "string" ? post.author : undefined;
@@ -260,8 +196,7 @@ function derive(s: S): S {
260
196
  noPost: !hasPost,
261
197
  hasCover: Boolean(s.cover),
262
198
  noCover: !s.cover,
263
- hasBlocks: s.blocks.length > 0,
264
- noBlocks: s.blocks.length === 0,
199
+ hasBodyJson: s.bodyJson !== "",
265
200
  notLiked: !s.liked,
266
201
  notSaved: !s.saved,
267
202
  commentsAvailable: !s.commentsUnavailable,
@@ -276,10 +211,9 @@ const baseState: S = derive({
276
211
  byline: "",
277
212
  countsLine: "",
278
213
  excerpt: "",
279
- body: "",
280
- blocks: [],
281
- hasBlocks: false,
282
- noBlocks: true,
214
+ bodyJson: "",
215
+ hasBodyJson: false,
216
+ bodyText: "",
283
217
  shareUrl: "",
284
218
  cover: "",
285
219
  hasCover: false,
@@ -321,7 +255,9 @@ async function cachedPostState(s: S, id: string): Promise<S> {
321
255
  title,
322
256
  subtitle: String(cached.meta ?? ""),
323
257
  excerpt: String(cached.excerpt ?? "").trim(),
324
- body: String(cached.body ?? cached.excerpt ?? "This post has no readable body yet."),
258
+ // The feed caches no Ricos body, so the offline copy is plain text (the descriptor's gated text shows it).
259
+ bodyJson: "",
260
+ bodyText: String(cached.body ?? cached.excerpt ?? "This post has no readable body yet."),
325
261
  cover: String(cached.cover ?? ""),
326
262
  initial: String(cached.initial ?? "") || (title[0] || "#").toUpperCase(),
327
263
  liked: false,
@@ -358,8 +294,8 @@ async function loadPostState(s: S): Promise<S> {
358
294
  byline: bylineOf(post),
359
295
  countsLine: countsLineOf(post),
360
296
  excerpt: (post.excerpt || "").trim(),
361
- body: bodyText(post),
362
- blocks: richContentBlocks(post.richContent),
297
+ // No Ricos body, or one the encoder rejects: bodyJson "" and the gated plain text shows bodyText.
298
+ ...richBody(post.richContent, bodyText(post)),
363
299
  shareUrl: postShareUrl(post),
364
300
  cover,
365
301
  initial: (title[0] || "#").toUpperCase(),
@@ -387,6 +323,8 @@ const svc = defineService<S, {
387
323
  share: (s: S) => Promise<S>;
388
324
  setComment: (s: S, arg?: unknown) => S;
389
325
  submitComment: (s: S) => Promise<S>;
326
+ /** [rico] #3370 — RichContent's on_link: `{ url }` of the tapped link. */
327
+ openLink: (s: S, arg?: unknown) => Promise<S>;
390
328
  }>("himiBlogPost", {
391
329
  state: baseState,
392
330
  actions: {
@@ -452,6 +390,12 @@ const svc = defineService<S, {
452
390
  });
453
391
  }
454
392
  },
393
+ // Only http(s), mailto and tel links reach open_url; a refused link, or one nothing opened, says so, and
394
+ // a link that opens clears that message rather than leaving an earlier failure on screen.
395
+ openLink: async (s, arg) => derive({
396
+ ...s,
397
+ actionMessage: (await openRichLink(arg)) ? "" : copyParam("blogPost.linkOpenFailed", "This link could not be opened."),
398
+ }),
455
399
  },
456
400
  });
457
401
 
@@ -82042,6 +82042,48 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
82042
82042
  }
82043
82043
  }
82044
82044
  },
82045
+ {
82046
+ "id": "post_body",
82047
+ "body": {
82048
+ "package": "himalaya",
82049
+ "type": "RichContent",
82050
+ "props": {
82051
+ "documentJsonPath": "$service:himiOwnerLitePost.bodyJson",
82052
+ "onLink": {
82053
+ "actionId": "tier5.himiOwnerLitePost.openLink"
82054
+ },
82055
+ "fallback": {
82056
+ "id": "",
82057
+ "body": {
82058
+ "package": "himalaya",
82059
+ "type": "Text",
82060
+ "props": {
82061
+ "text": "$service:himiOwnerLitePost.bodyText",
82062
+ "style": "body",
82063
+ "color": "textPrimary",
82064
+ "collapseWhenEmpty": true
82065
+ }
82066
+ }
82067
+ }
82068
+ }
82069
+ }
82070
+ },
82071
+ {
82072
+ "id": "post_body_text",
82073
+ "body": {
82074
+ "package": "himalaya",
82075
+ "type": "Text",
82076
+ "props": {
82077
+ "text": "$service:himiOwnerLitePost.bodyText",
82078
+ "style": "body",
82079
+ "color": "textPrimary",
82080
+ "collapseWhenEmpty": true
82081
+ }
82082
+ },
82083
+ "modifiers": {
82084
+ "hiddenPath": "$service:himiOwnerLitePost.hasBodyJson"
82085
+ }
82086
+ },
82045
82087
  {
82046
82088
  "id": "post_info_card",
82047
82089
  "body": {
@@ -82415,7 +82457,8 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
82415
82457
  "contentType": "application/javascript+ts-bundle",
82416
82458
  "declaredCapabilities": [
82417
82459
  "wix_api",
82418
- "local_database"
82460
+ "local_database",
82461
+ "open_url"
82419
82462
  ],
82420
82463
  "minBinaryVersion": "0.1.0",
82421
82464
  "bundleVersion": "0.0.1"
@@ -0,0 +1,28 @@
1
+ // [rico] #3370 — an entity's Ricos body, prepared for a RichContent component and its plain-text fallback.
2
+ // One rule for every worker that renders a body (the blog post and the owner post detail today; #3372's
3
+ // verticals next), so they cannot drift apart.
4
+ // Inside the platform source this import is a relative path, so node test runs resolve it; an app's copy
5
+ // imports "@himalaya/wix-rich-content".
6
+ import { encodeRichContent, parseRicosJson, ricosToPlainText } from "@himalaya/wix-rich-content";
7
+
8
+ /** What a body-rendering worker publishes: `<name>Json` for `documentJsonPath`, and the plain text. */
9
+ export interface RichBody {
10
+ /** The document as ONE JSON string (media sized, disallowed links dropped); "" when there is no document
11
+ * or the encoder rejected it. RichContent draws nothing for "", so a screen gates its plain text on it. */
12
+ bodyJson: string;
13
+ /** The plain text: the document's own text when it has any, else `fallback`. */
14
+ bodyText: string;
15
+ }
16
+
17
+ /** Prepares `richContent` (a Ricos document, as a Wix API returns it, or its JSON string). No document, or one
18
+ * that is not a document, gives `{ bodyJson: "", bodyText: fallback }`. A document past the viewer's limits
19
+ * gives bodyJson "" and its plain text — except one rejected as too deep, which is not walked again (the
20
+ * plain-text walk has no depth limit), so `fallback` stands in for it. */
21
+ export function richBody(richContent: unknown, fallback = ""): RichBody {
22
+ const doc = parseRicosJson(richContent);
23
+ if (!doc) return { bodyJson: "", bodyText: fallback };
24
+ // 1400 px — the detail-image budget, which is also the encoder's maximum.
25
+ const { json, rejected } = encodeRichContent(doc, { imageWidth: 1400 });
26
+ const text = rejected === "too_deep" ? "" : ricosToPlainText(doc);
27
+ return { bodyJson: json, bodyText: text || fallback };
28
+ }
@@ -0,0 +1,34 @@
1
+ // [rico] #3370 — a RichContent `on_link` tap reaches the worker as `{ url }` (the renderers merge the tapped
2
+ // link into the action's args). Only http(s) with a host, mailto and tel ever reach the OS; anything
3
+ // else is dropped here, before any dispatch. The renderers already refuse other schemes, but the action
4
+ // arrives here as plain data, so the worker checks it again rather than trusting whoever sent it.
5
+ import { open, defaultMode } from "@himalaya/open-url";
6
+
7
+ /** The viewer's link rule, verbatim (`LINK` in @himalaya/wix-rich-content's display.ts, which the package does
8
+ * not export); a parity test pins this copy to the original. */
9
+ const LINK = /^(https?:\/\/[^\s/?#]\S*|mailto:\S+|tel:\S+)$/i;
10
+ /** ASCII whitespace only, as the viewer trims a link before applying the rule. */
11
+ const trimAscii = (s: string): string => s.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, "");
12
+
13
+ /** The url of a link tap (`{ url }`, `{ args: { url } }` or a bare string), or "" when it is missing or
14
+ * not an http(s), mailto or tel link. */
15
+ export function richLinkUrl(arg: unknown): string {
16
+ const a = arg as { url?: unknown; args?: { url?: unknown } } | null | undefined;
17
+ const raw = typeof arg === "string" ? arg
18
+ : typeof a?.url === "string" ? a.url
19
+ : typeof a?.args?.url === "string" ? a.args.url : "";
20
+ const url = trimAscii(raw);
21
+ return LINK.test(url) ? url : "";
22
+ }
23
+
24
+ /** Opens a link tap through `capability.open_url` (http(s) in the in-app browser, mailto/tel in the system
25
+ * handler). False when the link is blocked, nothing took it, or the host threw. */
26
+ export async function openRichLink(arg: unknown): Promise<boolean> {
27
+ const url = richLinkUrl(arg);
28
+ if (!url) return false;
29
+ try {
30
+ return (await open(url, defaultMode(url))).opened;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
@@ -7,6 +7,9 @@ import { postCoverUrl } from "../_shared/owner-post-content.js";
7
7
 
8
8
  import { defineService } from "@himalaya/state";
9
9
  import { getPost, getDraftPost, publishDraftPost, revertDraftPost, deleteDraftPost } from "@himalaya/wix-blog";
10
+ import { parseRicosJson } from "@himalaya/wix-rich-content";
11
+ import { openRichLink } from "../_shared/rich-content-link.js";
12
+ import { richBody } from "../_shared/rich-body.js";
10
13
  import { ownerLoadError, isBridgeUnavailable, isOwnerPreviewDemo, shouldQueueOwnerWrite } from "../_shared/owner-load-error.js";
11
14
  import { DEMO_POSTS, DEMO_NOTE, DEMO_SAVE_NOTE } from "../_shared/owner-preview-demo.js";
12
15
  import { stashCopy, copyParam } from "../_shared/himi-copy.js";
@@ -25,6 +28,14 @@ async function cachePut(row: DbEntity): Promise<void> { try { await dbUpsert(ENT
25
28
  async function cacheDrop(id: string): Promise<void> { try { await dbRemove(ENTITY, id); } catch { /* capability not granted */ } }
26
29
  async function cacheGet(id: string): Promise<DbEntity | null> { try { return await dbGet(ENTITY, id); } catch { return null; } }
27
30
  async function queueWrite(actionId: string, params: Record<string, string>): Promise<void> { try { await dbEnqueue(actionId, params); } catch { /* capability not granted */ } }
31
+ /** [rico] #3370 — keep the body's plain text with the post's cached row, so the offline detail shows it. It is
32
+ * merged into the row the posts list wrote rather than replacing it; a later list re-sync writes its own rows
33
+ * and drops it again, until the detail is next opened online. */
34
+ async function cacheBodyText(s: PostState): Promise<void> {
35
+ if (s.status !== "ready" || !s.id) return;
36
+ const prev = await cacheGet(s.id);
37
+ await cachePut(prev ? { ...prev, bodyText: s.bodyText } : cacheRow(s));
38
+ }
28
39
 
29
40
  interface PostState {
30
41
  id: string;
@@ -38,6 +49,14 @@ interface PostState {
38
49
  summary: string;
39
50
  initial: string;
40
51
  image: string;
52
+ // Body ([rico] #3370) — RichContent binds bodyJson; the plain text shows when there is no document.
53
+ /** The body as ONE Ricos document JSON string for RichContent, from the draft when there is one; "" when the post
54
+ * has none, the encoder rejected it, or this is the offline copy. */
55
+ bodyJson: string;
56
+ /** bodyJson !== "" — gates the plain text, because RichContent draws nothing for "". */
57
+ hasBodyJson: boolean;
58
+ /** Plain text of the body: RichContent's `fallback`, and the offline copy's body. */
59
+ bodyText: string;
41
60
  // Info rows
42
61
  postStatus: string;
43
62
  category: string;
@@ -118,6 +137,9 @@ function shape(s: Partial<PostState>): PostState {
118
137
  summary: s.summary ?? "",
119
138
  initial: s.initial ?? "?",
120
139
  image,
140
+ bodyJson: s.bodyJson ?? "",
141
+ hasBodyJson: !!s.bodyJson,
142
+ bodyText: s.bodyText ?? "",
121
143
  postStatus: s.postStatus ?? statusLabel(raw),
122
144
  category: s.category ?? "—",
123
145
  published: s.published ?? "—",
@@ -153,6 +175,8 @@ function demoDetail(s: PostState, id: string): PostState | null {
153
175
  summary: d.excerpt,
154
176
  initial: (d.title[0] || "?").toUpperCase(),
155
177
  image: "",
178
+ bodyJson: "",
179
+ bodyText: d.body,
156
180
  postStatus: d.postStatus,
157
181
  category: d.category,
158
182
  published: d.published,
@@ -173,6 +197,8 @@ function cacheRow(s: PostState): DbEntity {
173
197
  summary: s.summary,
174
198
  initial: s.initial,
175
199
  image: s.image,
200
+ // The plain text only: the offline copy shows it, and a stale document is never drawn from cache.
201
+ bodyText: s.bodyText,
176
202
  postStatus: s.postStatus,
177
203
  category: s.category,
178
204
  published: s.published,
@@ -194,6 +220,10 @@ function cachedDetail(s: PostState, row: DbEntity): PostState {
194
220
  summary: String(row.summary ?? row.excerpt ?? ""),
195
221
  initial: String(row.initial ?? (title[0] || "?")).slice(0, 1).toUpperCase(),
196
222
  image: String(row.image ?? ""),
223
+ bodyJson: "",
224
+ // The post form upserts a whole row with `body` and no `bodyText`; cacheBodyText later merges `bodyText` into it.
225
+ // So a present `bodyText` is the newer value, and an explicit "" (a body cleared online) is kept.
226
+ bodyText: String(row.bodyText ?? row.body ?? ""),
197
227
  postStatus: row.postStatus != null ? String(row.postStatus) : statusLabel(raw),
198
228
  category: String(row.category ?? "—"),
199
229
  published: String(row.published ?? "—"),
@@ -221,6 +251,10 @@ async function refresh(prev: PostState, id: string, actionMessage = ""): Promise
221
251
  const slug = draft?.seoSlug || post?.slug || "";
222
252
  const draftHidesCover = (draft?.media ?? draft?.coverMedia)?.displayed === false;
223
253
  const image = draftHidesCover ? "" : coverUrl(draft) || coverUrl(post);
254
+ // The draft carries the owner's latest edits, so its body wins over the published one. With no usable
255
+ // document, the published post's own plain text (CONTENT_TEXT) is the body.
256
+ const body = richBody(parseRicosJson(draft?.richContent) ? draft?.richContent : post?.richContent,
257
+ typeof post?.contentText === "string" ? post.contentText.trim() : "");
224
258
  return shape({
225
259
  ...prev,
226
260
  id,
@@ -230,6 +264,7 @@ async function refresh(prev: PostState, id: string, actionMessage = ""): Promise
230
264
  summary: (draft?.excerpt || post?.excerpt || "").trim(),
231
265
  initial: (title[0] || "?").toUpperCase(),
232
266
  image,
267
+ ...body,
233
268
  postStatus: statusLabel(raw),
234
269
  category: "—",
235
270
  published: fmtDate(post?.firstPublishedDate),
@@ -247,6 +282,8 @@ const svc = defineService<PostState, {
247
282
  publish: (s: PostState) => Promise<PostState>;
248
283
  unpublish: (s: PostState) => Promise<PostState>;
249
284
  delete: (s: PostState) => Promise<PostState>;
285
+ /** [rico] #3370 — RichContent's on_link: `{ url }` of the tapped link. */
286
+ openLink: (s: PostState, arg?: unknown) => Promise<PostState>;
250
287
  }>("himiOwnerLitePost", {
251
288
  state: shape({ status: "loading" }),
252
289
  actions: {
@@ -261,7 +298,9 @@ const svc = defineService<PostState, {
261
298
  return shape({ ...s, status: "error", error: "Sample item not found." });
262
299
  }
263
300
  try {
264
- return await refresh(shape({ ...s, id, status: "loading" }), id);
301
+ const loaded = await refresh(shape({ ...s, id, status: "loading" }), id);
302
+ await cacheBodyText(loaded);
303
+ return loaded;
265
304
  } catch (e) {
266
305
  // Offline: fall back to the last-synced local row before the demo/error paths.
267
306
  const cached = await cacheGet(id);
@@ -282,7 +321,9 @@ const svc = defineService<PostState, {
282
321
  return shape({ ...s, status: "error", error: "Sample item not found." });
283
322
  }
284
323
  try {
285
- return await refresh(shape({ ...s, status: "loading" }), s.id);
324
+ const loaded = await refresh(shape({ ...s, status: "loading" }), s.id);
325
+ await cacheBodyText(loaded);
326
+ return loaded;
286
327
  } catch (e) {
287
328
  const cached = await cacheGet(s.id);
288
329
  if (cached) return cachedDetail(s, cached);
@@ -347,6 +388,12 @@ const svc = defineService<PostState, {
347
388
  return shape({ ...s, actionMessage: ownerLoadError(e) });
348
389
  }
349
390
  },
391
+ // Only http(s), mailto and tel links reach open_url; a refused link, or one nothing opened, says so, and
392
+ // a link that opens clears that message rather than leaving an earlier failure on screen.
393
+ openLink: async (s, arg) => shape({
394
+ ...s,
395
+ actionMessage: (await openRichLink(arg)) ? "" : copyParam("ownerPost.linkOpenFailed", "This link could not be opened."),
396
+ }),
350
397
  },
351
398
  });
352
399