Setting the file. One moment.
Posts Core · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 16.49
Position 49 of 828
Type TypeScript
Size 17 KB
Lines 349 blog/app/wix/blog/ posts-core.ts
TypeScript · 349 lines · 17 KB
11
/** A raw Blog V3 entity as either transport returns it. */
12 export type Raw = Record < string , any >;
13 /** Media value + size → https URL. Injected: the SDK transport scales through @wix/sdk, REST through the URL form. */
14 export type ImgSrc = ( value : any , width : number , height : number ) => string ;
15
16 /** The Wix Blog app id (Astro item-page routing; comments use it as appId). */
17 export const BLOG_APP_ID = "14bcded7-0066-7c35-14d7-466cb3f09103" ;
18
19 /** Where the blog lives in the site's URL space; every path helper below builds on it. */
20 export const BLOG_BASE = "/blog" ;
21
22 /**
23 * The post page's fieldsets — without them richContent, contentText, seoData, and referenceId come
24 * back undefined. SEO carries the owner's title/description overrides. The comments thread id is
25 * `referenceId`: verified live, the REFERENCE_ID fieldset returns the post WITHOUT it (silently),
26 * while INTERNAL_ID returns both `internalId` and `referenceId` — so both are requested, and the
27 * mapper falls back to the post id (which is what referenceId equalled on every post read live, and
28 * what Wix's own Blog widget addresses the thread by). (Fieldset enum: URL, CONTENT_TEXT, METRICS,
29 * SEO, CONTACT_ID, RICH_CONTENT, REFERENCE_ID; INTERNAL_ID is accepted on the wire.)
30 */
31 export const DETAIL_FIELDSETS = [ "RICH_CONTENT" , "CONTENT_TEXT" , "SEO" , "REFERENCE_ID" , "INTERNAL_ID" ] as const ;
32
33 /**
34 * The card fieldsets: METRICS puts view/like/comment counts on every post of a page in one read. The
35 * field is deprecated on the SDK's Post type ("data can be inconsistent") but live on the wire; the
36 * post page refreshes it with the dedicated metrics read (fetchPostMetrics / fetchLikeState).
37 */
38 export const CARD_FIELDSETS = [ "METRICS" ] as const ;
39
40 /**
41 * The feed's order: pinned posts first, then newest first. Only listPosts' FEED default pins on its
42 * own; queryPosts with an explicit sort is pure date order, so the pinned key is spelled out here.
43 */
44 export const FEED_SORT = [
45 { fieldName: "pinned" , order: "DESC" },
46 { fieldName: "firstPublishedDate" , order: "DESC" },
47 ] as const ;
48
49 /** Related posts: newest first among posts sharing a category (no pinned key — Wix's related strip has none). */
50 export const RECENT_SORT = [{ fieldName: "firstPublishedDate" , order: "DESC" }] as const ;
51
52 /** How many related posts a post page shows (Wix's default; curated lists are capped the same way). */
53 export const RELATED_LIMIT = 3 ;
54
55 /** The Like service scopes likes by entity FQDN; blog posts use the V3 post FQDN. */
56 export const BLOG_POST_FQDN = "wix.blog.v3.post" ;
57
58 /** Meta descriptions are cut here — the Blog API caps its own `excerpt` at the same length. */
59 export const SEO_DESCRIPTION_MAX = 500 ;
60
61 /** Cover size every card and post header gets — 16:9, one scaled URL per post. */
62 export const COVER_WIDTH = 1200 ;
63 export const COVER_HEIGHT = 675 ;
64
65 export const rawId = ( raw : Raw | undefined | null ) : string => raw?._id ?? raw?.id ?? "" ;
66
67 // ---- URLs ----------------------------------------------------------------------------------
68
69 /** A slug may contain "/" — encode per segment so the path keeps its shape and a `[...slug]` route re-joins it. */
70 export const encodeSlugPath = ( slug : string ) : string => slug. split ( "/" ). map (encodeURIComponent). join ( "/" );
71 /** The post page: /blog/<slug>. Never build it from an id, never use `post.url` (the legacy blog page). */
72 export const postPath = ( slug : string ) : string => `${ BLOG_BASE }/${ encodeSlugPath ( slug ) }` ;
73 /** The category page: /blog/category/<slug>. */
74 export const categoryPath = ( slug : string ) : string => `${ BLOG_BASE }/category/${ encodeSlugPath ( slug ) }` ;
75 /** The tag page: /blog/tag/<slug>. */
76 export const tagPath = ( slug : string ) : string => `${ BLOG_BASE }/tag/${ encodeSlugPath ( slug ) }` ;
77
78 // ---- media ---------------------------------------------------------------------------------
79
80 /**
81 * A media value in the form imgSrc scales. The SDK hands over the `wix:image://v1/<file>/<name>#…`
82 * string; REST hands over the Image object { id, url, width, height, filename }. Rebuilding the
83 * wix:image form from the object makes both transports scale to the SAME URL — passing the object's
84 * `url` through would skip scaling and the two paths would disagree on coverUrl.
85 */
86 export function mediaValue ( value : unknown ) : string {
87 if ( ! value) return "" ;
88 if ( typeof value === "string" ) return value;
89 const o = value as Raw ;
90 const id = o._id ?? o.id;
91 if (id && ! String (id). startsWith ( "http" )) {
92 const size = o.width && o.height ? `#originWidth=${ o . width }&originHeight=${ o . height }` : "" ;
93 return `wix:image://v1/${ id }/${ o . filename ?? ""}${ size }` ;
94 }
95 return o.url ?? o.image ?? "" ;
96 }
97
98 /**
99 * A video cover's poster as a wix:image value. The SDK's videoV2 is the string
100 * `wix:video://v1/<id>/<file>#posterUri=<file>&posterWidth=<w>&posterHeight=<h>`; REST's is the
101 * object { id, filename, posters: [{ id, url, width, height }] } (the SDK builds its string from
102 * the LAST poster). "" when the video carries no poster.
103 */
104 export function videoPoster ( value : unknown ) : string {
105 if ( ! value) return "" ;
106 if ( typeof value === "string" ) {
107 const hash = value. split ( "#" )[ 1 ] ?? "" ;
108 const params = new URLSearchParams (hash);
109 const uri = params. get ( "posterUri" );
110 if ( ! uri) return "" ;
111 const w = params. get ( "posterWidth" ), h = params. get ( "posterHeight" );
112 return `wix:image://v1/${ uri }/${ uri }${ w && h ? `#originWidth=${ w }&originHeight=${ h }` : ""}` ;
113 }
114 const posters : Raw [] = (value as Raw ).posters ?? [];
115 const poster = posters[posters. length - 1 ];
116 if ( ! poster) return "" ;
117 const id = poster.id || (poster.url ? String (poster.url). slice ( String (poster.url). lastIndexOf ( "/" ) + 1 ) : "" );
118 return id ? mediaValue ({ id, filename: id, width: poster.width, height: poster.height }) : "" ;
119 }
120
121 /**
122 * The cover as a card renders it. `media.displayed === false` is the author hiding the cover —
123 * no cover at all. Otherwise the image wins, then a Wix video's poster, then an external embed's
124 * thumbnail (YouTube/Vimeo). Alt text is the author's, else the title.
125 */
126 export function coverOf ( raw : Raw , imgSrc : ImgSrc ) : { coverUrl : string ; coverAlt : string } {
127 const media : Raw | undefined = raw.media;
128 if ( ! media || media.displayed === false ) return { coverUrl: "" , coverAlt: "" };
129 const coverUrl =
130 imgSrc ( mediaValue (media.wixMedia?.image), COVER_WIDTH , COVER_HEIGHT ) ||
131 imgSrc ( videoPoster (media.wixMedia?.videoV2), COVER_WIDTH , COVER_HEIGHT ) ||
132 (media.embedMedia?.thumbnail?.url ?? "" );
133 return { coverUrl, coverAlt: coverUrl ? (media.altText ?? raw.title ?? "" ) : "" };
134 }
135
136 // ---- dates and numbers -----------------------------------------------------------------------
137
138 /** Display date + ISO date from either a Date (SDK) or an ISO string (REST); both "" when missing or invalid. */
139 export function dateParts ( value : unknown ) : { dateLabel : string ; dateISO : string } {
140 if ( ! value) return { dateLabel: "" , dateISO: "" };
141 const d = value instanceof Date ? value : new Date ( String (value));
142 if (Number. isNaN (d. getTime ())) return { dateLabel: "" , dateISO: "" };
143 return {
144 dateLabel: d. toLocaleDateString ( undefined , { year: "numeric" , month: "short" , day: "numeric" }),
145 dateISO: d. toISOString (),
146 };
147 }
148
149 const DAY_MS = 86_400_000 ;
150
151 /**
152 * Wix's default date style: relative under a week ("3 days ago"), "Aug 5" in the current year,
153 * "Aug 5, 2024" otherwise. Pass the SAME `now` on the server and the client (e.g. a `fetchedAt`
154 * you put in the island's props) or the two renders disagree and hydration warns. "" when unknown.
155 */
156 export function relativeDateLabel ( dateISO : string , now : number = Date. now (), locale ?: string ) : string {
157 if ( ! dateISO) return "" ;
158 const d = new Date (dateISO);
159 if (Number. isNaN (d. getTime ())) return "" ;
160 const diff = now - d. getTime ();
161 if (diff >= 0 && diff < 7 * DAY_MS ) {
162 const rtf = new Intl. RelativeTimeFormat (locale, { numeric: "auto" });
163 if (diff < 60_000 ) return rtf. format ( - Math. round (diff / 1000 ), "second" );
164 if (diff < 3_600_000 ) return rtf. format ( - Math. round (diff / 60_000 ), "minute" );
165 if (diff < DAY_MS ) return rtf. format ( - Math. round (diff / 3_600_000 ), "hour" );
166 return rtf. format ( - Math. round (diff / DAY_MS ), "day" );
167 }
168 const sameYear = d. getFullYear () === new Date (now). getFullYear ();
169 return new Intl. DateTimeFormat (locale, { ... (sameYear ? {} : { year: "numeric" }), month: "short" , day: "numeric" }). format (d);
170 }
171
172 /** "1.2K" (compact, the default) or "1,234" (full) — counters. "" for an unknown count. */
173 export function formatCount ( n : number | undefined , style : "compact" | "full" = "compact" , locale ?: string ) : string {
174 if ( typeof n !== "number" || ! Number. isFinite (n)) return "" ;
175 return new Intl. NumberFormat (locale, style === "compact" ? { notation: "compact" , maximumFractionDigits: 1 } : {}). format (n);
176 }
177
178 /** "Aug 27, 2026 · 4 min read" — the card's and header's meta line; either half alone, "" when neither is known. */
179 export function postMetaLine ( post : Pick < PostSummary , "dateLabel" | "minutesToRead" >) : string {
180 return [post.dateLabel, post.minutesToRead > 0 ? `${ post . minutesToRead } min read` : "" ]. filter (Boolean). join ( " · " );
181 }
182
183 // ---- SEO overrides (the dashboard's Advanced SEO panel, on posts and categories) --------------
184
185 const liveSeoTags = ( seoData : Raw | undefined | null ) : Raw [] => ((seoData?.tags ?? []) as Raw []). filter (( t ) => ! t.disabled);
186
187 /** The owner's title override, "" when none. A title tag carries its value in `children`. */
188 export function seoTitleFrom ( seoData : Raw | undefined | null ) : string {
189 return liveSeoTags (seoData). find (( t ) => t.type === "title" )?.children || "" ;
190 }
191
192 /** The owner's meta-description override, "" when none: a `meta` tag with props.name "description". */
193 export function seoDescriptionFrom ( seoData : Raw | undefined | null ) : string {
194 const c = liveSeoTags (seoData). find (( t ) => t.type === "meta" && t.props?.name === "description" )?.props?.content;
195 return typeof c === "string" ? c : "" ;
196 }
197
198 // ---- mappers -------------------------------------------------------------------------------
199
200 const count = ( v : unknown ) : number | undefined => ( typeof v === "number" ? v : undefined );
201
202 /** The counters from a metrics object (a post's `metrics` field or the metrics read's `metrics`). */
203 export function toMetrics ( raw : Raw | undefined | null ) : PostMetrics {
204 return { views: count (raw?.views), likes: count (raw?.likes), comments: count (raw?.comments) };
205 }
206
207 export function toSummary ( raw : Raw , imgSrc : ImgSrc ) : PostSummary {
208 const metrics = toMetrics (raw.metrics);
209 return {
210 id: rawId (raw),
211 slug: raw.slug ?? "" ,
212 title: raw.title ?? "" ,
213 excerpt: raw.excerpt ?? "" ,
214 ... dateParts (raw.firstPublishedDate),
215 minutesToRead: raw.minutesToRead ?? 0 ,
216 featured: raw.featured === true ,
217 pinned: raw.pinned === true ,
218 ... coverOf (raw, imgSrc),
219 categoryIds: raw.categoryIds ?? [],
220 tagIds: raw.tagIds ?? [],
221 authorId: raw.memberId ?? "" ,
222 authorName: "" ,
223 authorAvatarUrl: "" ,
224 viewCount: metrics.views,
225 likeCount: metrics.likes,
226 commentCount: metrics.comments,
227 };
228 }
229
230 export function toDetail ( raw : Raw , imgSrc : ImgSrc ) : PostDetail {
231 const summary = toSummary (raw, imgSrc);
232 const contentText = String (raw.contentText ?? "" );
233 const updated = dateParts (raw.lastPublishedDate);
234 return {
235 ... summary,
236 richContent: raw.richContent ?? null ,
237 bodyHtml: richContentToHtml (raw.richContent, imgSrc),
238 // contentText is plain text — split on newlines for the fallback body.
239 paragraphs: contentText
240 . split ( " \n " )
241 . map (( s : string ) => s. trim ())
242 . filter (Boolean),
243 updatedLabel: updated.dateLabel,
244 updatedISO: updated.dateISO,
245 seoTitle: seoTitleFrom (raw.seoData) || summary.title,
246 seoDescription: ( seoDescriptionFrom (raw.seoData) || summary.excerpt || contentText). slice ( 0 , SEO_DESCRIPTION_MAX ),
247 relatedPostIds: raw.relatedPostIds ?? [],
248 commentingEnabled: raw.commentingEnabled === true ,
249 referenceId: raw.referenceId ?? raw.internalId ?? raw.id ?? "" ,
250 };
251 }
252
253 /** Join resolved authors onto posts; a post whose author did not resolve keeps "" (no byline). */
254 export function withAuthors < T extends PostSummary >( posts : T [], authors : Record < string , BlogAuthor >) : T [] {
255 return posts. map (( p ) => {
256 const a = authors[p.authorId];
257 return a ? { ... p, authorName: a.name, authorAvatarUrl: a.avatarUrl } : p;
258 });
259 }
260
261 // ---- queries -------------------------------------------------------------------------------
262
263 export interface FetchPostsOptions {
264 limit ?: number ;
265 /** `nextCursor` from a previous page. */
266 cursor ?: string | null ;
267 /** Server-side filters (first page only — the cursor carries them on later pages). */
268 categoryId ?: string | null ;
269 tagId ?: string | null ;
270 }
271
272 /**
273 * The feed query as one object — the REST body's `query`, and the rule the SDK builder in ./posts.ts
274 * spells with .descending()/.hasSome()/.skipTo(). A cursor encodes the original filter+sort, so a
275 * cursor request carries ONLY cursorPaging; the first page carries the sort and the filters.
276 */
277 export function feedQuery ({ limit = 20 , cursor , categoryId , tagId } : FetchPostsOptions = {}) : Raw {
278 if (cursor) return { cursorPaging: { limit, cursor } };
279 const filter : Raw = {};
280 if (categoryId) filter.categoryIds = { $hasSome: [categoryId] };
281 if (tagId) filter.tagIds = { $hasSome: [tagId] };
282 return { ... (Object. keys (filter). length ? { filter } : {}), sort: FEED_SORT , cursorPaging: { limit } };
283 }
284
285 /** The by-slug query (REST body's `query`): exact slug, one row. A documented fallback — the by-slug getter is the primary read. */
286 export function slugQuery ( slug : string ) : Raw {
287 return { filter: { slug: { $eq: slug } }, cursorPaging: { limit: 1 } };
288 }
289
290 /** Posts by id (REST body's `query`; the SDK spells it .in("_id", ids)) — the curated related posts. */
291 export function byIdsQuery ( ids : string [], limit : number = ids. length ) : Raw {
292 return { filter: { id: { $in: ids } }, cursorPaging: { limit } };
293 }
294
295 /**
296 * The algorithmic related posts: newest first among posts sharing a category with the current one,
297 * the current one excluded. A post with no categories gets plain site-wide recents — a
298 * `$hasSome: []` matches nothing, so the category filter is added only when there is one.
299 */
300 export function recentRelatedQuery ( post : Pick < PostSummary , "id" | "categoryIds" >, limit = RELATED_LIMIT ) : Raw {
301 const filter : Raw = { id: { $ne: post.id } };
302 if (post.categoryIds. length ) filter.categoryIds = { $hasSome: post.categoryIds };
303 return { filter, sort: RECENT_SORT , cursorPaging: { limit } };
304 }
305
306 /**
307 * The curated list in the WRITER's order (the query answers in its own), the current post dropped,
308 * ids that no longer resolve to a visible post dropped, capped at `limit`. Strictly either/or with
309 * the algorithmic list: when at least one curated pick survives, exactly those are shown, never
310 * topped up.
311 */
312 export function pickCurated < T extends { id : string }>( ids : string [], items : T [], currentId : string , limit = RELATED_LIMIT ) : T [] {
313 const byId = new Map (items. map (( p ) => [p.id, p]));
314 return ids
315 . filter (( id ) => id !== currentId)
316 . map (( id ) => byId. get (id))
317 . filter (( p ) : p is T => p !== undefined )
318 . slice ( 0 , limit);
319 }
320
321 /**
322 * "Did THIS viewer like the post?" — the Like service scopes queryLikes to the calling identity
323 * (member or anonymous visitor), so one row means yes. REST body's `query`; the SDK spells it
324 * .eq("fqdn").eq("entityId").limit(1). Personal → client-only, never during SSR.
325 */
326 export function likeStateQuery ( postId : string ) : Raw {
327 return { filter: { fqdn: { $eq: BLOG_POST_FQDN }, entityId: { $eq: postId } }, cursorPaging: { limit: 1 } };
328 }
329
330 /** The like write's body — the same object for createLike and for the REST POST. */
331 export function likeBody ( postId : string ) : Raw {
332 return { like: { entityId: postId, fqdn: BLOG_POST_FQDN } };
333 }
334
335 /**
336 * An expected miss, not a failure: a 404, or an application error whose code names NOT_FOUND.
337 * Guarded with String() — the SDK's transformed error carries a NUMERIC code for HTTP failures.
338 */
339 export function isNotFound ( e : unknown ) : boolean {
340 const err = (e ?? {}) as { status ?: unknown ; code ?: unknown ; details ?: { applicationError ?: { code ?: unknown } } };
341 return err.status === 404 || String (err.details?.applicationError?.code ?? err.code ?? "" ). includes ( "NOT_FOUND" );
342 }
343
344 /** A write the caller's identity may not perform — an anonymous visitor commenting where members only may. */
345 export function isPermissionDenied ( e : unknown ) : boolean {
346 const err = (e ?? {}) as { status ?: unknown ; code ?: unknown ; details ?: { applicationError ?: { code ?: unknown } } };
347 const code = String (err.details?.applicationError?.code ?? err.code ?? "" );
348 return err.status === 401 || err.status === 403 || /PERMISSION_DENIED | UNAUTHENTICATED | FORBIDDEN/ . test (code);
349 }