← Files WixARCHIVED FILE
skills/wix-headless-templates/blog/project/src/wix/blog/comments-core.ts
8.83 KB · Oct 8, 2026 · 12:02 UTC
// Comment rules and DTO mapping — transport-agnostic, imported by both ./comments.ts and the REST
// twin in templates/blog/rest/comments.ts. Wix Comments is one service for every app; a blog post's
// thread is addressed by the Blog app id plus the post's REFERENCE ID as both contextId and
// resourceId (the public-SDK shape Wix's own headless blog uses; the classic Blog widget uses the
// post id through an internal client instead — verify on a live site that the dashboard's threads
// come back). Threads are two levels deep: a reply is stored under its TOP-level comment, and its
// `parentComment` may itself be a reply ("replying to"). Imports are type-only.
import type { BlogAuthor, BlogComment, CommentPage, CommentStatus, CommentThread } from "./types";
import type { Raw } from "./posts-core";
// A copy of posts-core's BLOG_APP_ID and dateParts, so this file stands alone when stripped.
const BLOG_APP_ID = "14bcded7-0066-7c35-14d7-466cb3f09103";
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() };
}
/** Top-level comments per page (Wix's default) and replies per "show more replies". */
export const COMMENTS_PAGE_SIZE = 10;
export const REPLIES_PAGE_SIZE = 10;
/** The two orders a blog thread offers; replies are always oldest first. */
export type CommentSort = "NEWEST_FIRST" | "OLDEST_FIRST";
export const DEFAULT_COMMENT_SORT: CommentSort = "NEWEST_FIRST";
export interface ListCommentsOptions {
/** `PostDetail.referenceId` — the thread. */
referenceId: string;
cursor?: string | null;
limit?: number;
sort?: CommentSort;
}
/**
* The list-by-resource request as flat query-string params (the endpoint is a GET; nested fields
* travel as dotted keys, the SDK flattens them the same way). `repliesLimit: 0` asks for the reply
* SEAMS only — each top-level comment's reply cursor comes back without its replies, which load on
* demand with `listRepliesParams`.
*/
export function listCommentsParams({ referenceId, cursor, limit = COMMENTS_PAGE_SIZE, sort = DEFAULT_COMMENT_SORT }: ListCommentsOptions): Record<string, string> {
return {
appId: BLOG_APP_ID,
contextId: referenceId,
resourceId: referenceId,
"commentSort.order": sort,
"replySort.order": "OLDEST_FIRST",
"cursorPaging.limit": String(limit),
"cursorPaging.repliesLimit": "0",
...(cursor ? { "cursorPaging.cursor": cursor } : {}),
};
}
/** The same GET, continuing ONE top-level comment's replies from the cursor the list handed back. */
export function listRepliesParams({ referenceId, cursor, limit = REPLIES_PAGE_SIZE }: { referenceId: string; cursor: string; limit?: number }): Record<string, string> {
return {
appId: BLOG_APP_ID,
contextId: referenceId,
resourceId: referenceId,
"replySort.order": "OLDEST_FIRST",
"cursorPaging.limit": String(limit),
"cursorPaging.cursor": cursor,
};
}
export interface CreateCommentOptions {
referenceId: string;
/** Plain text — wrapped into a one-paragraph Ricos document. */
text?: string;
/** Or a ready Ricos document (from a rich editor). */
richContent?: Record<string, unknown>;
/** Reply to this comment (a top-level comment or a reply). */
parentId?: string | null;
}
/** A one-paragraph Ricos document: TEXT is always a leaf inside a PARAGRAPH; the leaf's id is "". */
export function textToRichContent(text: string): Record<string, unknown> {
return {
nodes: [
{ type: "PARAGRAPH", id: "c1", nodes: [{ type: "TEXT", id: "", nodes: [], textData: { text, decorations: [] } }], paragraphData: {} },
],
};
}
/** The create body: `{ comment }`, the reply's parent as `parentComment.id`. */
export function createCommentBody({ referenceId, text, richContent, parentId }: CreateCommentOptions): Raw {
return {
comment: {
appId: BLOG_APP_ID,
contextId: referenceId,
resourceId: referenceId,
content: { richContent: richContent ?? textToRichContent(text ?? "") },
...(parentId ? { parentComment: { id: parentId } } : {}),
},
};
}
/** The plain text of a Ricos document — every TEXT leaf, paragraphs joined by newlines. */
export function richContentText(rc: Raw | null | undefined): string {
const walk = (node: Raw): string =>
node.type === "TEXT" ? (node.textData?.text ?? "") : ((node.nodes ?? []) as Raw[]).map(walk).join("");
return ((rc?.nodes ?? []) as Raw[]).map(walk).map((s) => s.trim()).filter(Boolean).join("\n");
}
const STATUSES: readonly CommentStatus[] = ["PUBLISHED", "PENDING", "DELETED", "HIDDEN"];
export interface CommentContext {
authors: Record<string, BlogAuthor>;
currentMemberId: string | null;
/** The top-level comment a reply hangs under; null for top-level comments. */
topLevelId: string | null;
}
export function toComment(raw: Raw, { authors, currentMemberId, topLevelId }: CommentContext): BlogComment {
const status: CommentStatus = STATUSES.includes(raw.status) ? raw.status : "UNKNOWN";
// A PENDING comment (awaiting the owner's approval) carries its text as draftContent, not content.
const content: Raw | null = raw.content?.richContent ?? (status === "PENDING" ? raw.draftContent?.richContent : null) ?? null;
const authorId: string = raw.author?.memberId ?? "";
const author = authors[authorId];
const parentAuthor = authors[raw.parentComment?.author?.memberId ?? ""];
return {
id: raw._id ?? raw.id ?? "",
topLevelId,
parentId: raw.parentComment?._id ?? raw.parentComment?.id ?? null,
parentAuthorName: parentAuthor?.name ?? "",
authorId,
authorName: author?.name ?? "",
authorAvatarUrl: author?.avatarUrl ?? "",
isOwn: Boolean(currentMemberId) && authorId === currentMemberId,
...dateParts(raw.commentDate ?? raw._createdDate ?? raw.createdDate),
status,
content,
text: richContentText(content),
replyCount: typeof raw.replyCount === "number" ? raw.replyCount : 0,
};
}
/** Every member id a raw comment list references (authors and reply-to authors), for one batched lookup. */
export function commentAuthorIds(raws: readonly Raw[]): string[] {
return [...new Set(raws.flatMap((c) => [c.author?.memberId, c.parentComment?.author?.memberId]).filter((id): id is string => Boolean(id)))];
}
/** All raw comments a list response carries — the top-level ones and every seam's replies. */
export function rawCommentsOf(res: Raw | null | undefined): Raw[] {
const replies = Object.values((res?.commentReplies ?? {}) as Record<string, Raw>).flatMap((r) => (r.replies ?? []) as Raw[]);
return [...((res?.comments ?? []) as Raw[]), ...replies];
}
export function toCommentPage(res: Raw | null | undefined, ctx: Omit<CommentContext, "topLevelId">): CommentPage {
const replies: Record<string, CommentThread> = {};
for (const [topLevelId, r] of Object.entries((res?.commentReplies ?? {}) as Record<string, Raw>)) {
replies[topLevelId] = {
comments: ((r.replies ?? []) as Raw[]).map((c) => toComment(c, { ...ctx, topLevelId })),
nextCursor: r.pagingMetadata?.cursors?.next ?? null,
};
}
return {
comments: ((res?.comments ?? []) as Raw[]).map((c) => toComment(c, { ...ctx, topLevelId: null })),
nextCursor: res?.pagingMetadata?.cursors?.next ?? null,
replies,
};
}
/** A replies page: the same response shape, its `comments` being the replies of one top-level comment. */
export function toCommentThread(res: Raw | null | undefined, ctx: CommentContext): CommentThread {
return {
comments: ((res?.comments ?? []) as Raw[]).map((c) => toComment(c, ctx)),
nextCursor: res?.pagingMetadata?.cursors?.next ?? null,
};
}
/** Re-mark ownership once the current member is known (the list and the member read run in parallel). */
export function markOwn<T extends Pick<BlogComment, "authorId" | "isOwn">>(items: T[], currentMemberId: string | null): T[] {
return items.map((c) => ({ ...c, isOwn: Boolean(currentMemberId) && c.authorId === currentMemberId }));
}
/** A deleted comment that still has replies stays as a placeholder: no author, no body. */
export function toDeletedPlaceholder(c: BlogComment): BlogComment {
return { ...c, status: "DELETED", content: null, text: "", authorId: "", authorName: "", authorAvatarUrl: "", isOwn: false };
}
/** Merge a page into a list by id, keeping the existing order and appending what is new. */
export function mergeById<T extends { id: string }>(existing: readonly T[], incoming: readonly T[]): T[] {
const byId = new Map(existing.map((c) => [c.id, c]));
for (const c of incoming) byId.set(c.id, c);
const seen = new Set(existing.map((c) => c.id));
return [...existing.map((c) => byId.get(c.id)!), ...incoming.filter((c) => !seen.has(c.id))];
}
SHA-256: a58e9902520892e51ccf8180905aa823e354f285cd27f9221d39a179375a9a42