← Files WixARCHIVED FILE
skills/wix-headless-templates/blog/project/src/wix/blog/posts-core.ts
17.2 KB · Oct 8, 2026 · 12:02 UTC
// Post rules and DTO mapping — transport-agnostic, imported by BOTH transports: ./posts.ts (the
// SDK, managed Astro and React) and the REST twin in templates/blog/rest/posts.ts (fetch, a static
// site or a port to another language). Every rule about dates, covers, the feed's sort and filters,
// the body fieldsets, SEO overrides, related posts, likes, and URLs lives HERE, once. A raw post may
// come from the SDK (`_id`, media as a `wix:image://` string, dates as Date) or from REST (`id`,
// media as an { id, url } object, dates as ISO strings); the mappers accept both and produce the
// same DTO. Imports are type-only so a strip to JS emits no imports.
import type { BlogAuthor, PostDetail, PostMetrics, PostSummary } from "./types";
/** A raw Blog V3 entity as either transport returns it. */
export type Raw = Record<string, any>;
/** Media value + size → https URL. Injected: the SDK transport scales through @wix/sdk, REST through the URL form. */
export type ImgSrc = (value: any, width: number, height: number) => string;
/** The Wix Blog app id (Astro item-page routing; comments use it as appId). */
export const BLOG_APP_ID = "14bcded7-0066-7c35-14d7-466cb3f09103";
/** Where the blog lives in the site's URL space; every path helper below builds on it. */
export const BLOG_BASE = "/blog";
/**
* The post page's fieldsets — without them richContent, contentText, seoData, and referenceId come
* back undefined. SEO carries the owner's title/description overrides. The comments thread id is
* `referenceId`: verified live, the REFERENCE_ID fieldset returns the post WITHOUT it (silently),
* while INTERNAL_ID returns both `internalId` and `referenceId` — so both are requested, and the
* mapper falls back to the post id (which is what referenceId equalled on every post read live, and
* what Wix's own Blog widget addresses the thread by). (Fieldset enum: URL, CONTENT_TEXT, METRICS,
* SEO, CONTACT_ID, RICH_CONTENT, REFERENCE_ID; INTERNAL_ID is accepted on the wire.)
*/
export const DETAIL_FIELDSETS = ["RICH_CONTENT", "CONTENT_TEXT", "SEO", "REFERENCE_ID", "INTERNAL_ID"] as const;
/**
* The card fieldsets: METRICS puts view/like/comment counts on every post of a page in one read. The
* field is deprecated on the SDK's Post type ("data can be inconsistent") but live on the wire; the
* post page refreshes it with the dedicated metrics read (fetchPostMetrics / fetchLikeState).
*/
export const CARD_FIELDSETS = ["METRICS"] as const;
/**
* The feed's order: pinned posts first, then newest first. Only listPosts' FEED default pins on its
* own; queryPosts with an explicit sort is pure date order, so the pinned key is spelled out here.
*/
export const FEED_SORT = [
{ fieldName: "pinned", order: "DESC" },
{ fieldName: "firstPublishedDate", order: "DESC" },
] as const;
/** Related posts: newest first among posts sharing a category (no pinned key — Wix's related strip has none). */
export const RECENT_SORT = [{ fieldName: "firstPublishedDate", order: "DESC" }] as const;
/** How many related posts a post page shows (Wix's default; curated lists are capped the same way). */
export const RELATED_LIMIT = 3;
/** The Like service scopes likes by entity FQDN; blog posts use the V3 post FQDN. */
export const BLOG_POST_FQDN = "wix.blog.v3.post";
/** Meta descriptions are cut here — the Blog API caps its own `excerpt` at the same length. */
export const SEO_DESCRIPTION_MAX = 500;
/** Cover size every card and post header gets — 16:9, one scaled URL per post. */
export const COVER_WIDTH = 1200;
export const COVER_HEIGHT = 675;
export const rawId = (raw: Raw | undefined | null): string => raw?._id ?? raw?.id ?? "";
// ---- URLs ----------------------------------------------------------------------------------
/** A slug may contain "/" — encode per segment so the path keeps its shape and a `[...slug]` route re-joins it. */
export const encodeSlugPath = (slug: string): string => slug.split("/").map(encodeURIComponent).join("/");
/** The post page: /blog/<slug>. Never build it from an id, never use `post.url` (the legacy blog page). */
export const postPath = (slug: string): string => `${BLOG_BASE}/${encodeSlugPath(slug)}`;
/** The category page: /blog/category/<slug>. */
export const categoryPath = (slug: string): string => `${BLOG_BASE}/category/${encodeSlugPath(slug)}`;
/** The tag page: /blog/tag/<slug>. */
export const tagPath = (slug: string): string => `${BLOG_BASE}/tag/${encodeSlugPath(slug)}`;
// ---- media ---------------------------------------------------------------------------------
/**
* A media value in the form imgSrc scales. The SDK hands over the `wix:image://v1/<file>/<name>#…`
* string; REST hands over the Image object { id, url, width, height, filename }. Rebuilding the
* wix:image form from the object makes both transports scale to the SAME URL — passing the object's
* `url` through would skip scaling and the two paths would disagree on coverUrl.
*/
export function mediaValue(value: unknown): string {
if (!value) return "";
if (typeof value === "string") return value;
const o = value as Raw;
const id = o._id ?? o.id;
if (id && !String(id).startsWith("http")) {
const size = o.width && o.height ? `#originWidth=${o.width}&originHeight=${o.height}` : "";
return `wix:image://v1/${id}/${o.filename ?? ""}${size}`;
}
return o.url ?? o.image ?? "";
}
/**
* A video cover's poster as a wix:image value. The SDK's videoV2 is the string
* `wix:video://v1/<id>/<file>#posterUri=<file>&posterWidth=<w>&posterHeight=<h>`; REST's is the
* object { id, filename, posters: [{ id, url, width, height }] } (the SDK builds its string from
* the LAST poster). "" when the video carries no poster.
*/
export function videoPoster(value: unknown): string {
if (!value) return "";
if (typeof value === "string") {
const hash = value.split("#")[1] ?? "";
const params = new URLSearchParams(hash);
const uri = params.get("posterUri");
if (!uri) return "";
const w = params.get("posterWidth"), h = params.get("posterHeight");
return `wix:image://v1/${uri}/${uri}${w && h ? `#originWidth=${w}&originHeight=${h}` : ""}`;
}
const posters: Raw[] = (value as Raw).posters ?? [];
const poster = posters[posters.length - 1];
if (!poster) return "";
const id = poster.id || (poster.url ? String(poster.url).slice(String(poster.url).lastIndexOf("/") + 1) : "");
return id ? mediaValue({ id, filename: id, width: poster.width, height: poster.height }) : "";
}
/**
* The cover as a card renders it. `media.displayed === false` is the author hiding the cover —
* no cover at all. Otherwise the image wins, then a Wix video's poster, then an external embed's
* thumbnail (YouTube/Vimeo). Alt text is the author's, else the title.
*/
export function coverOf(raw: Raw, imgSrc: ImgSrc): { coverUrl: string; coverAlt: string } {
const media: Raw | undefined = raw.media;
if (!media || media.displayed === false) return { coverUrl: "", coverAlt: "" };
const coverUrl =
imgSrc(mediaValue(media.wixMedia?.image), COVER_WIDTH, COVER_HEIGHT) ||
imgSrc(videoPoster(media.wixMedia?.videoV2), COVER_WIDTH, COVER_HEIGHT) ||
(media.embedMedia?.thumbnail?.url ?? "");
return { coverUrl, coverAlt: coverUrl ? (media.altText ?? raw.title ?? "") : "" };
}
// ---- dates and numbers -----------------------------------------------------------------------
/** Display date + ISO date from either a Date (SDK) or an ISO string (REST); both "" when missing or invalid. */
export function dateParts(value: unknown): { dateLabel: string; dateISO: string } {
if (!value) return { dateLabel: "", dateISO: "" };
const d = value instanceof Date ? value : new Date(String(value));
if (Number.isNaN(d.getTime())) return { dateLabel: "", dateISO: "" };
return {
dateLabel: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }),
dateISO: d.toISOString(),
};
}
const DAY_MS = 86_400_000;
/**
* Wix's default date style: relative under a week ("3 days ago"), "Aug 5" in the current year,
* "Aug 5, 2024" otherwise. Pass the SAME `now` on the server and the client (e.g. a `fetchedAt`
* you put in the island's props) or the two renders disagree and hydration warns. "" when unknown.
*/
export function relativeDateLabel(dateISO: string, now: number = Date.now(), locale?: string): string {
if (!dateISO) return "";
const d = new Date(dateISO);
if (Number.isNaN(d.getTime())) return "";
const diff = now - d.getTime();
if (diff >= 0 && diff < 7 * DAY_MS) {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (diff < 60_000) return rtf.format(-Math.round(diff / 1000), "second");
if (diff < 3_600_000) return rtf.format(-Math.round(diff / 60_000), "minute");
if (diff < DAY_MS) return rtf.format(-Math.round(diff / 3_600_000), "hour");
return rtf.format(-Math.round(diff / DAY_MS), "day");
}
const sameYear = d.getFullYear() === new Date(now).getFullYear();
return new Intl.DateTimeFormat(locale, { ...(sameYear ? {} : { year: "numeric" }), month: "short", day: "numeric" }).format(d);
}
/** "1.2K" (compact, the default) or "1,234" (full) — counters. "" for an unknown count. */
export function formatCount(n: number | undefined, style: "compact" | "full" = "compact", locale?: string): string {
if (typeof n !== "number" || !Number.isFinite(n)) return "";
return new Intl.NumberFormat(locale, style === "compact" ? { notation: "compact", maximumFractionDigits: 1 } : {}).format(n);
}
/** "Aug 27, 2026 · 4 min read" — the card's and header's meta line; either half alone, "" when neither is known. */
export function postMetaLine(post: Pick<PostSummary, "dateLabel" | "minutesToRead">): string {
return [post.dateLabel, post.minutesToRead > 0 ? `${post.minutesToRead} min read` : ""].filter(Boolean).join(" · ");
}
// ---- SEO overrides (the dashboard's Advanced SEO panel, on posts and categories) --------------
const liveSeoTags = (seoData: Raw | undefined | null): Raw[] => ((seoData?.tags ?? []) as Raw[]).filter((t) => !t.disabled);
/** The owner's title override, "" when none. A title tag carries its value in `children`. */
export function seoTitleFrom(seoData: Raw | undefined | null): string {
return liveSeoTags(seoData).find((t) => t.type === "title")?.children || "";
}
/** The owner's meta-description override, "" when none: a `meta` tag with props.name "description". */
export function seoDescriptionFrom(seoData: Raw | undefined | null): string {
const c = liveSeoTags(seoData).find((t) => t.type === "meta" && t.props?.name === "description")?.props?.content;
return typeof c === "string" ? c : "";
}
// ---- mappers -------------------------------------------------------------------------------
const count = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
/** The counters from a metrics object (a post's `metrics` field or the metrics read's `metrics`). */
export function toMetrics(raw: Raw | undefined | null): PostMetrics {
return { views: count(raw?.views), likes: count(raw?.likes), comments: count(raw?.comments) };
}
export function toSummary(raw: Raw, imgSrc: ImgSrc): PostSummary {
const metrics = toMetrics(raw.metrics);
return {
id: rawId(raw),
slug: raw.slug ?? "",
title: raw.title ?? "",
excerpt: raw.excerpt ?? "",
...dateParts(raw.firstPublishedDate),
minutesToRead: raw.minutesToRead ?? 0,
featured: raw.featured === true,
pinned: raw.pinned === true,
...coverOf(raw, imgSrc),
categoryIds: raw.categoryIds ?? [],
tagIds: raw.tagIds ?? [],
authorId: raw.memberId ?? "",
authorName: "",
authorAvatarUrl: "",
viewCount: metrics.views,
likeCount: metrics.likes,
commentCount: metrics.comments,
};
}
export function toDetail(raw: Raw, imgSrc: ImgSrc): PostDetail {
const summary = toSummary(raw, imgSrc);
const contentText = String(raw.contentText ?? "");
const updated = dateParts(raw.lastPublishedDate);
return {
...summary,
richContent: raw.richContent ?? null,
// contentText is plain text — split on newlines for the fallback body.
paragraphs: contentText
.split("\n")
.map((s: string) => s.trim())
.filter(Boolean),
updatedLabel: updated.dateLabel,
updatedISO: updated.dateISO,
seoTitle: seoTitleFrom(raw.seoData) || summary.title,
seoDescription: (seoDescriptionFrom(raw.seoData) || summary.excerpt || contentText).slice(0, SEO_DESCRIPTION_MAX),
relatedPostIds: raw.relatedPostIds ?? [],
commentingEnabled: raw.commentingEnabled === true,
referenceId: raw.referenceId ?? raw.internalId ?? raw.id ?? "",
};
}
/** Join resolved authors onto posts; a post whose author did not resolve keeps "" (no byline). */
export function withAuthors<T extends PostSummary>(posts: T[], authors: Record<string, BlogAuthor>): T[] {
return posts.map((p) => {
const a = authors[p.authorId];
return a ? { ...p, authorName: a.name, authorAvatarUrl: a.avatarUrl } : p;
});
}
// ---- queries -------------------------------------------------------------------------------
export interface FetchPostsOptions {
limit?: number;
/** `nextCursor` from a previous page. */
cursor?: string | null;
/** Server-side filters (first page only — the cursor carries them on later pages). */
categoryId?: string | null;
tagId?: string | null;
}
/**
* The feed query as one object — the REST body's `query`, and the rule the SDK builder in ./posts.ts
* spells with .descending()/.hasSome()/.skipTo(). A cursor encodes the original filter+sort, so a
* cursor request carries ONLY cursorPaging; the first page carries the sort and the filters.
*/
export function feedQuery({ limit = 20, cursor, categoryId, tagId }: FetchPostsOptions = {}): Raw {
if (cursor) return { cursorPaging: { limit, cursor } };
const filter: Raw = {};
if (categoryId) filter.categoryIds = { $hasSome: [categoryId] };
if (tagId) filter.tagIds = { $hasSome: [tagId] };
return { ...(Object.keys(filter).length ? { filter } : {}), sort: FEED_SORT, cursorPaging: { limit } };
}
/** The by-slug query (REST body's `query`): exact slug, one row. A documented fallback — the by-slug getter is the primary read. */
export function slugQuery(slug: string): Raw {
return { filter: { slug: { $eq: slug } }, cursorPaging: { limit: 1 } };
}
/** Posts by id (REST body's `query`; the SDK spells it .in("_id", ids)) — the curated related posts. */
export function byIdsQuery(ids: string[], limit: number = ids.length): Raw {
return { filter: { id: { $in: ids } }, cursorPaging: { limit } };
}
/**
* The algorithmic related posts: newest first among posts sharing a category with the current one,
* the current one excluded. A post with no categories gets plain site-wide recents — a
* `$hasSome: []` matches nothing, so the category filter is added only when there is one.
*/
export function recentRelatedQuery(post: Pick<PostSummary, "id" | "categoryIds">, limit = RELATED_LIMIT): Raw {
const filter: Raw = { id: { $ne: post.id } };
if (post.categoryIds.length) filter.categoryIds = { $hasSome: post.categoryIds };
return { filter, sort: RECENT_SORT, cursorPaging: { limit } };
}
/**
* The curated list in the WRITER's order (the query answers in its own), the current post dropped,
* ids that no longer resolve to a visible post dropped, capped at `limit`. Strictly either/or with
* the algorithmic list: when at least one curated pick survives, exactly those are shown, never
* topped up.
*/
export function pickCurated<T extends { id: string }>(ids: string[], items: T[], currentId: string, limit = RELATED_LIMIT): T[] {
const byId = new Map(items.map((p) => [p.id, p]));
return ids
.filter((id) => id !== currentId)
.map((id) => byId.get(id))
.filter((p): p is T => p !== undefined)
.slice(0, limit);
}
/**
* "Did THIS viewer like the post?" — the Like service scopes queryLikes to the calling identity
* (member or anonymous visitor), so one row means yes. REST body's `query`; the SDK spells it
* .eq("fqdn").eq("entityId").limit(1). Personal → client-only, never during SSR.
*/
export function likeStateQuery(postId: string): Raw {
return { filter: { fqdn: { $eq: BLOG_POST_FQDN }, entityId: { $eq: postId } }, cursorPaging: { limit: 1 } };
}
/** The like write's body — the same object for createLike and for the REST POST. */
export function likeBody(postId: string): Raw {
return { like: { entityId: postId, fqdn: BLOG_POST_FQDN } };
}
/**
* An expected miss, not a failure: a 404, or an application error whose code names NOT_FOUND.
* Guarded with String() — the SDK's transformed error carries a NUMERIC code for HTTP failures.
*/
export function isNotFound(e: unknown): boolean {
const err = (e ?? {}) as { status?: unknown; code?: unknown; details?: { applicationError?: { code?: unknown } } };
return err.status === 404 || String(err.details?.applicationError?.code ?? err.code ?? "").includes("NOT_FOUND");
}
/** A write the caller's identity may not perform — an anonymous visitor commenting where members only may. */
export function isPermissionDenied(e: unknown): boolean {
const err = (e ?? {}) as { status?: unknown; code?: unknown; details?: { applicationError?: { code?: unknown } } };
const code = String(err.details?.applicationError?.code ?? err.code ?? "");
return err.status === 401 || err.status === 403 || /PERMISSION_DENIED|UNAUTHENTICATED|FORBIDDEN/.test(code);
}
SHA-256: 317d4c63ac5f1481fb3c06527fbbf1127ef1097c29902a184a94033774774ba6