17 "js/wix/authors-core.js": "// Declarations of authors-core.ts: what this file exports, their arguments and what they return.\n// import type { BlogAuthor } from \"./blog-types.js\";\n// import type { ImgSrc, Raw } from \"./posts-core.js\";\n// export declare const AUTHOR_FIELDSETS: readonly [\"PUBLIC\"];\n// export declare const AVATAR_SIZE = 96;\n// export declare function uniqueIds(ids: readonly (string | null | undefined)[]): string[];\n// export declare function authorsQuery(ids: string[]): Raw;\n// export declare function photoValue(photo: Raw | undefined | null): string;\n// export declare function toAuthor(raw: Raw, imgSrc: ImgSrc): BlogAuthor;\n// export declare function indexAuthors(authors: BlogAuthor[]): Record<string, BlogAuthor>;\n/** The PUBLIC fieldset covers everything a byline needs: `profile.nickname` and `profile.photo`. */\nexport const AUTHOR_FIELDSETS = [\"PUBLIC\"];\n/** Avatar size every byline gets — one scaled square URL per author. */\nexport const AVATAR_SIZE = 96;\n/** Distinct, non-empty ids in first-seen order — one query per page, never one per card. */\nexport function uniqueIds(ids) {\n return [...new Set(ids.filter((id) => Boolean(id)))];\n}\n/**\n * The members query as the REST body — the SDK spells it queryMembers({ fieldsets }).in(\"_id\", ids)\n * .limit(n). Members page by OFFSET (`paging`), unlike posts.\n */\nexport function authorsQuery(ids) {\n return { fieldsets: AUTHOR_FIELDSETS, query: { filter: { id: { $in: ids } }, paging: { limit: ids.length } } };\n}\n/**\n * A member photo in the form imgSrc scales. A Wix-hosted photo carries a media id (`_id` on the\n * SDK, `id` on REST) → the wix:image form; an avatar imported from a social login is an external\n * URL with an EMPTY id → passed through as-is (imgSrc leaves non-Wix URLs alone).\n */\nexport function photoValue(photo) {\n if (!photo)\n return \"\";\n const id = photo._id ?? photo.id;\n if (id)\n return `wix:image://v1/${id}/${id}${photo.width && photo.height ? `#originWidth=${photo.width}&originHeight=${photo.height}` : \"\"}`;\n return photo.url ?? \"\";\n}\nexport function toAuthor(raw, imgSrc) {\n return {\n id: raw._id ?? raw.id ?? \"\",\n name: raw.profile?.nickname ?? \"\",\n avatarUrl: imgSrc(photoValue(raw.profile?.photo), AVATAR_SIZE, AVATAR_SIZE),\n };\n}\n/** Authors keyed by id — a plain object, not a Map, so it survives serialization into island props. */\nexport function indexAuthors(authors) {\n return Object.fromEntries(authors.filter((a) => a.id).map((a) => [a.id, a]));\n}\n",
18 "js/wix/authors.js": "// Declarations of authors.ts: what this file exports, their arguments and what they return.\n// import type { BlogAuthor } from \"./blog-types.js\";\n// export declare function fetchAuthorsByIds(ids: readonly (string | null | undefined)[]): Promise<Record<string, BlogAuthor>>;\n// export declare function fetchCurrentMemberId(): Promise<string | null>;\n// Author reads over REST — the twin of app/wix/blog/authors.ts. Same exports, same DTOs; the\n// mappers come from authors-core (the SAME file the SDK transport uses, deployed flat next to this\n// one). Both reads are non-fatal: authors are decoration on a card. Porting: keep the paths, keep\n// the bodies, port the core once.\n// docs: https://dev.wix.com/docs/api-reference/crm/members-contacts/members/member-management/members/query-members.md\n// docs: https://dev.wix.com/docs/api-reference/crm/members-contacts/members/member-management/members/get-my-member.md\nimport { wixRequest } from \"./client.js\";\nimport { imgSrc } from \"./media.js\";\nimport { authorsQuery, indexAuthors, toAuthor, uniqueIds } from \"./authors-core.js\";\n/**\n * Every distinct author of one page in ONE request, keyed by member id. {} on failure.\n * POST /members/v1/members/query { fieldsets: [\"PUBLIC\"], query: { filter: { id: { $in } }, paging: { limit } } }\n */\nexport async function fetchAuthorsByIds(ids) {\n const unique = uniqueIds(ids);\n if (!unique.length)\n return {};\n try {\n const res = await wixRequest(\"/members/v1/members/query\", { body: authorsQuery(unique) });\n return indexAuthors((res?.members ?? []).map((m) => toAuthor(m, imgSrc)));\n }\n catch {\n return {};\n }\n}\n/**\n * The current member's id, or null for an anonymous visitor (401/403 → null).\n * GET /members/v1/members/my\n */\nexport async function fetchCurrentMemberId() {\n try {\n const res = await wixRequest(\"/members/v1/members/my\", { method: \"GET\" });\n return res?.member?.id ?? null;\n }\n catch {\n return null;\n }\n}\n",
19 "js/wix/blog-feed-store.js": "// Declarations of blog-feed-store.ts: what this file exports, their arguments and what they return.\n// import type { BlogCategory, BlogTag, PostPage, PostSummary } from \"./blog-types.js\";\n// export interface BlogFeedStoreOptions {\n// initialPage?: PostPage;\n// initialCategoryId?: string | null;\n// initialTagId?: string | null;\n// initialCategories?: BlogCategory[];\n// initialTags?: BlogTag[];\n// pageSize?: number;\n// }\n// export interface BlogFeedState {\n// posts: PostSummary[] | null;\n// categories: BlogCategory[];\n// tags: BlogTag[];\n// activeCategoryId: string | null;\n// activeTagId: string | null;\n// hasMore: boolean;\n// loadingMore: boolean;\n// error: string | null;\n// }\n// export interface BlogFeedStore {\n// getState(): BlogFeedState;\n// subscribe(listener: (state: BlogFeedState) => void): () => void;\n// start(): void;\n// stop(): void;\n// setActiveCategoryId(id: string | null): void;\n// setActiveTagId(id: string | null): void;\n// loadMore(): Promise<void>;\n// }\n// export declare function createBlogFeedStore({ initialPage, initialCategoryId, initialTagId, initialCategories, initialTags, pageSize, }?: BlogFeedStoreOptions): BlogFeedStore;\n// The blog feed as a framework-free store — the logic behind useBlogFeed, usable from React\n// (useBlogFeed wraps it with useSyncExternalStore), from a static page or Vue/Svelte (subscribe and\n// render), or as the specification for a port. State, actions, subscribe/getState, emit after every\n// change — the same shape as storefront's shop-store.\n//\n// Category and tag filters are applied by Wix (fetchPosts), never to a page already in hand; the\n// two are mutually exclusive — setting one clears the other. A changed filter starts a fresh cursor\n// chain; a late response from a superseded query is dropped. Paging appends by cursor. Author\n// bylines and counters ride on the posts themselves (fetchPosts joins them per page).\n//\n// SSR-friendly: seed with `initialPage` (and `initialCategories`/`initialTags`) and the first page\n// renders at once with no client fetch. A category or tag page seeds a FILTERED first page: pass the\n// filter it was fetched with as `initialCategoryId`/`initialTagId`, and the seed is trusted for\n// exactly that filter. A SPA passes nothing and `start()` fetches. One store per mounted feed (a\n// page can hold a feed and a \"latest posts\" rail): createBlogFeedStore().\n//\n// Error vs empty: a failed read leaves `posts: []` AND sets `error`. Render the empty state only\n// when `error` is null — \"No posts yet\" over a network failure is a lie.\nimport { fetchPosts } from \"./posts.js\";\nimport { fetchBlogCategories, fetchBlogTags } from \"./taxonomy.js\";\nexport function createBlogFeedStore({ initialPage, initialCategoryId = null, initialTagId = null, initialCategories, initialTags, pageSize = 20, } = {}) {\n let posts = initialPage?.posts ?? null;\n let cursor = initialPage?.nextCursor ?? null;\n let categories = initialCategories ?? [];\n let tags = initialTags ?? [];\n let activeCategoryId = initialCategoryId;\n let activeTagId = initialTagId;\n let loadingMore = false;\n let error = null;\n let started = false;\n let generation = 0; // bumped by every filter change and stop(); a response from an older generation is dropped\n const listeners = new Set();\n let snapshot = null;\n const emit = () => { snapshot = null; for (const fn of listeners)\n fn(); };\n function getState() {\n if (snapshot)\n return snapshot;\n snapshot = { posts, categories, tags, activeCategoryId, activeTagId, hasMore: cursor !== null, loadingMore, error };\n return snapshot;\n }\n // The seeded page answers exactly the filter it was fetched with — nothing else.\n const seedMatches = () => Boolean(initialPage) && activeCategoryId === initialCategoryId && activeTagId === initialTagId;\n // The first page for the current filter: the seed when it matches, a fetch otherwise.\n function query() {\n if (!started)\n return;\n const id = ++generation;\n loadingMore = false;\n if (seedMatches()) {\n posts = initialPage.posts;\n cursor = initialPage.nextCursor;\n error = null;\n emit();\n return;\n }\n posts = null;\n cursor = null;\n error = null;\n emit();\n fetchPosts({ limit: pageSize, categoryId: activeCategoryId, tagId: activeTagId })\n .then((page) => {\n if (generation !== id)\n return; // superseded — drop it\n posts = page.posts;\n cursor = page.nextCursor;\n emit();\n })\n .catch((e) => {\n if (generation !== id)\n return;\n posts = [];\n cursor = null;\n error = e instanceof Error ? e.message : String(e);\n emit();\n });\n }\n return {\n getState,\n subscribe(fn) {\n const listener = () => { try {\n fn(getState());\n }\n catch (e) {\n console.error(\"blog-feed-store listener failed\", e);\n } };\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n start() {\n if (started)\n return;\n started = true;\n if (!initialCategories)\n fetchBlogCategories().then((c) => { if (started) {\n categories = c;\n emit();\n } });\n if (!initialTags)\n fetchBlogTags().then((t) => { if (started) {\n tags = t;\n emit();\n } });\n query();\n },\n stop() { started = false; generation++; },\n setActiveCategoryId(id) {\n if (id === activeCategoryId && activeTagId === null)\n return;\n activeCategoryId = id;\n activeTagId = null;\n query();\n },\n setActiveTagId(id) {\n if (id === activeTagId && activeCategoryId === null)\n return;\n activeTagId = id;\n activeCategoryId = null;\n query();\n },\n async loadMore() {\n if (!cursor || loadingMore)\n return;\n const id = generation;\n loadingMore = true;\n error = null;\n emit();\n try {\n const page = await fetchPosts({ limit: pageSize, cursor });\n if (generation !== id)\n return;\n posts = [...(posts ?? []), ...page.posts];\n cursor = page.nextCursor;\n }\n catch (e) {\n if (generation !== id)\n return;\n error = e instanceof Error ? e.message : String(e);\n }\n finally {\n if (generation === id) {\n loadingMore = false;\n emit();\n }\n }\n },\n };\n}\n",
20 "js/wix/blog-types.js": "// Declarations of blog-types.ts: what this file exports, their arguments and what they return.\n// /** A post as a feed/grid tile needs it. */\n// export interface PostSummary {\n// id: string;\n// slug: string;\n// title: string;\n// /** Short summary (≤500 chars) — the card body. May be \"\". */\n// excerpt: string;\n// /** Display-ready publish date, e.g. \"Aug 26, 2026\" (\"\" when missing). */\n// dateLabel: string;\n// /** ISO publish date for <time datetime> (\"\" when missing). */\n// dateISO: string;\n// /** Estimated reading time in minutes (0 when unknown — render nothing, never \"0 min read\"). */\n// minutesToRead: number;\n// featured: boolean;\n// /** Pinned posts lead the default feed order. */\n// pinned: boolean;\n// /**\n// * Resolved https cover URL, 16:9 (\"\" when the post has no cover, or its author hid it). A video\n// * cover resolves to its poster, an external embed (YouTube/Vimeo) to its thumbnail.\n// */\n// coverUrl: string;\n// /** Alt text for the cover: the author's alt text, else the title (\"\" when there is no cover). */\n// coverAlt: string;\n// categoryIds: string[];\n// tagIds: string[];\n// /** The author's member id (\"\" when none). */\n// authorId: string;\n// /** The author's display name (\"\" when unresolved — render no byline then). */\n// authorName: string;\n// /** Resolved https avatar URL (\"\" when none). */\n// authorAvatarUrl: string;\n// /** Counters (the METRICS fieldset). undefined = unknown → render nothing. */\n// viewCount?: number;\n// likeCount?: number;\n// commentCount?: number;\n// }\n// /** A post as the post page needs it. */\n// export interface PostDetail extends PostSummary {\n// /**\n// * Ricos rich-content document (plain JSON) — the real post body. Render it ONLY through\n// * the shipped RichContent component (@wix/ricos viewer); it is not HTML and not text.\n// */\n// richContent: Record<string, unknown> | null;\n// /**\n// * The same body as sanitized HTML (\"\" when richContent is null), for a page without the React\n// * viewer: headings, lists, links, bold and the rest, images, tables. Set it as the body's innerHTML.\n// */\n// bodyHtml: string;\n// /** Plain-text body split into paragraphs — the honest fallback when richContent is null. */\n// paragraphs: string[];\n// /** Display-ready last-published date (\"\" when the post was never republished). */\n// updatedLabel: string;\n// updatedISO: string;\n// /** The owner's SEO title override, else the title — the <title> on stacks without the SEO service. */\n// seoTitle: string;\n// /** The owner's meta-description override, else the excerpt, else the body text cut to 500 chars. */\n// seoDescription: string;\n// /** Writer-curated related posts, in the writer's order (may be empty). */\n// relatedPostIds: string[];\n// /** Comments are open on this post (the dashboard's per-post switch). */\n// commentingEnabled: boolean;\n// /** The comments thread id (the REFERENCE_ID fieldset); \"\" when the API answered without it. */\n// referenceId: string;\n// }\n// /** A post author — a site member's public profile. */\n// export interface BlogAuthor {\n// id: string;\n// /** Display name (\"\" when the member set none). */\n// name: string;\n// /** Resolved https avatar URL (\"\" when none). */\n// avatarUrl: string;\n// }\n// /** The post's counters as the API's dedicated metrics read answers them. */\n// export interface PostMetrics {\n// views?: number;\n// likes?: number;\n// comments?: number;\n// }\n// /** The viewer's like state for one post, plus the fresh counters read alongside it. */\n// export interface LikeState {\n// /** Whether THIS viewer (member or anonymous visitor) has liked the post. */\n// liked: boolean;\n// /** null when the metrics read failed — keep the count you had. */\n// metrics: PostMetrics | null;\n// }\n// export interface BlogCategory {\n// id: string;\n// slug: string;\n// /** Display name (the API calls it `label`, never `name`). */\n// label: string;\n// /** The category's SEO title (\"\" when the owner set none) — the <title> of a category page. */\n// title: string;\n// description: string;\n// /** Number of posts in the category, checked against the latest posts (hide empty categories with it). */\n// postCount: number;\n// /** Resolved https cover URL (\"\" when none). */\n// coverUrl: string;\n// }\n// export interface BlogTag {\n// id: string;\n// slug: string;\n// label: string;\n// /** Number of PUBLISHED posts with this tag. */\n// postCount: number;\n// }\n// /** One feed page; pass `nextCursor` back to fetch the next (null → no more). */\n// export interface PostPage {\n// posts: PostSummary[];\n// nextCursor: string | null;\n// }\n// /** PUBLISHED is the normal state; PENDING awaits the owner's approval; DELETED is a placeholder kept for its replies. */\n// export type CommentStatus = \"PUBLISHED\" | \"PENDING\" | \"DELETED\" | \"HIDDEN\" | \"UNKNOWN\";\n// /** One comment — a top-level comment or a reply. Threads are two levels deep (Wix's rule). */\n// export interface BlogComment {\n// id: string;\n// /** The top-level comment this reply hangs under; null for a top-level comment. */\n// topLevelId: string | null;\n// /** The comment this one replies to (may itself be a reply — show \"replying to\"); null for a top-level comment. */\n// parentId: string | null;\n// /** The parent's author name (\"\" when unknown or a top-level comment). */\n// parentAuthorName: string;\n// /** The author's member id (\"\" for a guest or a deleted comment). */\n// authorId: string;\n// /** The author's display name (\"\" when unresolved). */\n// authorName: string;\n// authorAvatarUrl: string;\n// /** True when the current member wrote it — show the delete control only then. */\n// isOwn: boolean;\n// dateLabel: string;\n// dateISO: string;\n// status: CommentStatus;\n// /** Ricos document (plain JSON) — render through RichContent where it deploys; a PENDING comment carries its draft. */\n// content: Record<string, unknown> | null;\n// /** The comment's plain text — the body on stacks without the Ricos viewer (\"\" for a DELETED placeholder). */\n// text: string;\n// /** Number of replies (any depth) under a top-level comment. */\n// replyCount: number;\n// }\n// /** The replies of one top-level comment; `nextCursor` continues them (null → none or all loaded). */\n// export interface CommentThread {\n// comments: BlogComment[];\n// nextCursor: string | null;\n// }\n// /** One page of top-level comments plus the reply seams the API answered for them. */\n// export interface CommentPage {\n// comments: BlogComment[];\n// nextCursor: string | null;\n// /** Keyed by top-level comment id — its replies (empty until loaded) and their cursor. */\n// replies: Record<string, CommentThread>;\n// }\n// Blog DTOs — the serializable shapes every hook, component, and page consumes.\n// Plain JSON: safe as Astro island props or across server/client boundaries. Cover images are\n// resolved https URLs; dates are pre-formatted display strings plus an ISO value for <time>.\n// A field that is \"\" or undefined means \"not known\" — render nothing for it, never a placeholder.\nexport {};\n",
21 "js/wix/comments-core.js": "// Declarations of comments-core.ts: what this file exports, their arguments and what they return.\n// import type { BlogAuthor, BlogComment, CommentPage, CommentThread } from \"./blog-types.js\";\n// import type { Raw } from \"./posts-core.js\";\n// export declare const COMMENTS_PAGE_SIZE = 10;\n// export declare const REPLIES_PAGE_SIZE = 10;\n// export type CommentSort = \"NEWEST_FIRST\" | \"OLDEST_FIRST\";\n// export declare const DEFAULT_COMMENT_SORT: CommentSort;\n// export interface ListCommentsOptions {\n// referenceId: string;\n// cursor?: string | null;\n// limit?: number;\n// sort?: CommentSort;\n// }\n// export declare function listCommentsParams({ referenceId, cursor, limit, sort }: ListCommentsOptions): Record<string, string>;\n// export declare function listRepliesParams({ referenceId, cursor, limit }: {\n// referenceId: string;\n// cursor: string;\n// limit?: number;\n// }): Record<string, string>;\n// export interface CreateCommentOptions {\n// referenceId: string;\n// text?: string;\n// richContent?: Record<string, unknown>;\n// parentId?: string | null;\n// }\n// export declare function textToRichContent(text: string): Record<string, unknown>;\n// export declare function createCommentBody({ referenceId, text, richContent, parentId }: CreateCommentOptions): Raw;\n// export declare function richContentText(rc: Raw | null | undefined): string;\n// export interface CommentContext {\n// authors: Record<string, BlogAuthor>;\n// currentMemberId: string | null;\n// topLevelId: string | null;\n// }\n// export declare function toComment(raw: Raw, { authors, currentMemberId, topLevelId }: CommentContext): BlogComment;\n// export declare function commentAuthorIds(raws: readonly Raw[]): string[];\n// export declare function rawCommentsOf(res: Raw | null | undefined): Raw[];\n// export declare function toCommentPage(res: Raw | null | undefined, ctx: Omit<CommentContext, \"topLevelId\">): CommentPage;\n// export declare function toCommentThread(res: Raw | null | undefined, ctx: CommentContext): CommentThread;\n// export declare function markOwn<T extends Pick<BlogComment, \"authorId\" | \"isOwn\">>(items: T[], currentMemberId: string | null): T[];\n// export declare function toDeletedPlaceholder(c: BlogComment): BlogComment;\n// export declare function mergeById<T extends {\n// id: string;\n// }>(existing: readonly T[], incoming: readonly T[]): T[];\n// A copy of posts-core's BLOG_APP_ID and dateParts, so this file stands alone when stripped.\nconst BLOG_APP_ID = \"14bcded7-0066-7c35-14d7-466cb3f09103\";\nfunction dateParts(value) {\n if (!value)\n return { dateLabel: \"\", dateISO: \"\" };\n const d = value instanceof Date ? value : new Date(String(value));\n if (Number.isNaN(d.getTime()))\n return { dateLabel: \"\", dateISO: \"\" };\n return { dateLabel: d.toLocaleDateString(undefined, { year: \"numeric\", month: \"short\", day: \"numeric\" }), dateISO: d.toISOString() };\n}\n/** Top-level comments per page (Wix's default) and replies per \"show more replies\". */\nexport const COMMENTS_PAGE_SIZE = 10;\nexport const REPLIES_PAGE_SIZE = 10;\nexport const DEFAULT_COMMENT_SORT = \"NEWEST_FIRST\";\n/**\n * The list-by-resource request as flat query-string params (the endpoint is a GET; nested fields\n * travel as dotted keys, the SDK flattens them the same way). `repliesLimit: 0` asks for the reply\n * SEAMS only — each top-level comment's reply cursor comes back without its replies, which load on\n * demand with `listRepliesParams`.\n */\nexport function listCommentsParams({ referenceId, cursor, limit = COMMENTS_PAGE_SIZE, sort = DEFAULT_COMMENT_SORT }) {\n return {\n appId: BLOG_APP_ID,\n contextId: referenceId,\n resourceId: referenceId,\n \"commentSort.order\": sort,\n \"replySort.order\": \"OLDEST_FIRST\",\n \"cursorPaging.limit\": String(limit),\n \"cursorPaging.repliesLimit\": \"0\",\n ...(cursor ? { \"cursorPaging.cursor\": cursor } : {}),\n };\n}\n/** The same GET, continuing ONE top-level comment's replies from the cursor the list handed back. */\nexport function listRepliesParams({ referenceId, cursor, limit = REPLIES_PAGE_SIZE }) {\n return {\n appId: BLOG_APP_ID,\n contextId: referenceId,\n resourceId: referenceId,\n \"replySort.order\": \"OLDEST_FIRST\",\n \"cursorPaging.limit\": String(limit),\n \"cursorPaging.cursor\": cursor,\n };\n}\n/** A one-paragraph Ricos document: TEXT is always a leaf inside a PARAGRAPH; the leaf's id is \"\". */\nexport function textToRichContent(text) {\n return {\n nodes: [\n { type: \"PARAGRAPH\", id: \"c1\", nodes: [{ type: \"TEXT\", id: \"\", nodes: [], textData: { text, decorations: [] } }], paragraphData: {} },\n ],\n };\n}\n/** The create body: `{ comment }`, the reply's parent as `parentComment.id`. */\nexport function createCommentBody({ referenceId, text, richContent, parentId }) {\n return {\n comment: {\n appId: BLOG_APP_ID,\n contextId: referenceId,\n resourceId: referenceId,\n content: { richContent: richContent ?? textToRichContent(text ?? \"\") },\n ...(parentId ? { parentComment: { id: parentId } } : {}),\n },\n };\n}\n/** The plain text of a Ricos document — every TEXT leaf, paragraphs joined by newlines. */\nexport function richContentText(rc) {\n const walk = (node) => node.type === \"TEXT\" ? (node.textData?.text ?? \"\") : (node.nodes ?? []).map(walk).join(\"\");\n return (rc?.nodes ?? []).map(walk).map((s) => s.trim()).filter(Boolean).join(\"\\n\");\n}\nconst STATUSES = [\"PUBLISHED\", \"PENDING\", \"DELETED\", \"HIDDEN\"];\nexport function toComment(raw, { authors, currentMemberId, topLevelId }) {\n const status = STATUSES.includes(raw.status) ? raw.status : \"UNKNOWN\";\n // A PENDING comment (awaiting the owner's approval) carries its text as draftContent, not content.\n const content = raw.content?.richContent ?? (status === \"PENDING\" ? raw.draftContent?.richContent : null) ?? null;\n const authorId = raw.author?.memberId ?? \"\";\n const author = authors[authorId];\n const parentAuthor = authors[raw.parentComment?.author?.memberId ?? \"\"];\n return {\n id: raw._id ?? raw.id ?? \"\",\n topLevelId,\n parentId: raw.parentComment?._id ?? raw.parentComment?.id ?? null,\n parentAuthorName: parentAuthor?.name ?? \"\",\n authorId,\n authorName: author?.name ?? \"\",\n authorAvatarUrl: author?.avatarUrl ?? \"\",\n isOwn: Boolean(currentMemberId) && authorId === currentMemberId,\n ...dateParts(raw.commentDate ?? raw._createdDate ?? raw.createdDate),\n status,\n content,\n text: richContentText(content),\n replyCount: typeof raw.replyCount === \"number\" ? raw.replyCount : 0,\n };\n}\n/** Every member id a raw comment list references (authors and reply-to authors), for one batched lookup. */\nexport function commentAuthorIds(raws) {\n return [...new Set(raws.flatMap((c) => [c.author?.memberId, c.parentComment?.author?.memberId]).filter((id) => Boolean(id)))];\n}\n/** All raw comments a list response carries — the top-level ones and every seam's replies. */\nexport function rawCommentsOf(res) {\n const replies = Object.values((res?.commentReplies ?? {})).flatMap((r) => (r.replies ?? []));\n return [...(res?.comments ?? []), ...replies];\n}\nexport function toCommentPage(res, ctx) {\n const replies = {};\n for (const [topLevelId, r] of Object.entries((res?.commentReplies ?? {}))) {\n replies[topLevelId] = {\n comments: (r.replies ?? []).map((c) => toComment(c, { ...ctx, topLevelId })),\n nextCursor: r.pagingMetadata?.cursors?.next ?? null,\n };\n }\n return {\n comments: (res?.comments ?? []).map((c) => toComment(c, { ...ctx, topLevelId: null })),\n nextCursor: res?.pagingMetadata?.cursors?.next ?? null,\n replies,\n };\n}\n/** A replies page: the same response shape, its `comments` being the replies of one top-level comment. */\nexport function toCommentThread(res, ctx) {\n return {\n comments: (res?.comments ?? []).map((c) => toComment(c, ctx)),\n nextCursor: res?.pagingMetadata?.cursors?.next ?? null,\n };\n}\n/** Re-mark ownership once the current member is known (the list and the member read run in parallel). */\nexport function markOwn(items, currentMemberId) {\n return items.map((c) => ({ ...c, isOwn: Boolean(currentMemberId) && c.authorId === currentMemberId }));\n}\n/** A deleted comment that still has replies stays as a placeholder: no author, no body. */\nexport function toDeletedPlaceholder(c) {\n return { ...c, status: \"DELETED\", content: null, text: \"\", authorId: \"\", authorName: \"\", authorAvatarUrl: \"\", isOwn: false };\n}\n/** Merge a page into a list by id, keeping the existing order and appending what is new. */\nexport function mergeById(existing, incoming) {\n const byId = new Map(existing.map((c) => [c.id, c]));\n for (const c of incoming)\n byId.set(c.id, c);\n const seen = new Set(existing.map((c) => c.id));\n return [...existing.map((c) => byId.get(c.id)), ...incoming.filter((c) => !seen.has(c.id))];\n}\n",
22 "js/wix/comments-store.js": "// Declarations of comments-store.ts: what this file exports, their arguments and what they return.\n// import { type CommentSort } from \"./comments-core.js\";\n// import type { BlogComment, CommentThread } from \"./blog-types.js\";\n// export { type CommentSort };\n// export interface CommentsStoreOptions {\n// referenceId: string;\n// pageSize?: number;\n// sort?: CommentSort;\n// }\n// export interface ReplyThreadState extends CommentThread {\n// loading: boolean;\n// }\n// export interface CommentsState {\n// comments: BlogComment[] | null;\n// replies: Record<string, ReplyThreadState>;\n// hasMore: boolean;\n// loadingMore: boolean;\n// saving: boolean;\n// needsSignIn: boolean;\n// currentMemberId: string | null;\n// sort: CommentSort;\n// error: string | null;\n// }\n// export interface CommentsStore {\n// getState(): CommentsState;\n// subscribe(listener: (state: CommentsState) => void): () => void;\n// start(): void;\n// stop(): void;\n// loadMore(): Promise<void>;\n// loadReplies(topLevelId: string): Promise<void>;\n// setSort(sort: CommentSort): void;\n// create(text: string, opts?: {\n// parentId?: string | null;\n// topLevelId?: string | null;\n// }): Promise<BlogComment | null>;\n// remove(commentId: string): Promise<void>;\n// }\n// export declare function createCommentsStore({ referenceId, pageSize, sort: initialSort }: CommentsStoreOptions): CommentsStore;\n// A post's comment thread as a framework-free store — the logic behind useComments, usable from\n// React (useComments wraps it), from a static page, from Vue/Svelte, or as the specification for a\n// port. Top-level comments page by cursor; each one's replies load on demand from the seam the list\n// handed back; creating and deleting run as the current identity.\n//\n// Mount it only when `post.commentingEnabled && post.referenceId` — the referenceId IS the thread.\n// Whether guests may comment is the dashboard's \"Who can comment\" setting, decided by the server: a\n// refused create sets `needsSignIn` (render a sign-in prompt), never a client-side guess. The\n// site-wide comments master switch has no public read — a post whose owner turned comments off\n// site-wide still says commentingEnabled here.\nimport { fetchCurrentMemberId } from \"./authors.js\";\nimport { createComment, deleteComment, fetchComments, fetchReplies } from \"./comments.js\";\nimport { DEFAULT_COMMENT_SORT, markOwn, mergeById, toDeletedPlaceholder } from \"./comments-core.js\";\nimport { isPermissionDenied } from \"./posts-core.js\";\nexport function createCommentsStore({ referenceId, pageSize, sort: initialSort = DEFAULT_COMMENT_SORT }) {\n let comments = null;\n let replies = {};\n let cursor = null;\n let loadingMore = false;\n let saving = false;\n let needsSignIn = false;\n let currentMemberId = null;\n let sort = initialSort;\n let error = null;\n let started = false;\n let generation = 0;\n const listeners = new Set();\n let snapshot = null;\n const emit = () => { snapshot = null; for (const fn of listeners)\n fn(); };\n const message = (e) => (e instanceof Error ? e.message : String(e));\n function getState() {\n if (snapshot)\n return snapshot;\n snapshot = { comments, replies, hasMore: cursor !== null, loadingMore, saving, needsSignIn, currentMemberId, sort, error };\n return snapshot;\n }\n // The first page for the current sort; a changed sort restarts the list and drops late answers.\n async function load() {\n const id = ++generation;\n comments = null;\n replies = {};\n cursor = null;\n error = null;\n emit();\n try {\n const page = await fetchComments({ referenceId, limit: pageSize, sort, currentMemberId });\n if (generation !== id)\n return;\n comments = page.comments;\n cursor = page.nextCursor;\n replies = Object.fromEntries(Object.entries(page.replies).map(([k, t]) => [k, { ...t, loading: false }]));\n }\n catch (e) {\n if (generation !== id)\n return;\n comments = [];\n error = message(e);\n }\n emit();\n }\n return {\n getState,\n subscribe(fn) {\n const listener = () => { try {\n fn(getState());\n }\n catch (e) {\n console.error(\"comments-store listener failed\", e);\n } };\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n start() {\n if (started)\n return;\n started = true;\n // The member read and the list run in parallel; ownership is re-marked once the id is known.\n fetchCurrentMemberId().then((mid) => {\n if (!started)\n return;\n currentMemberId = mid;\n if (comments)\n comments = markOwn(comments, mid);\n replies = Object.fromEntries(Object.entries(replies).map(([k, t]) => [k, { ...t, comments: markOwn(t.comments, mid) }]));\n emit();\n });\n void load();\n },\n stop() { started = false; generation++; },\n async loadMore() {\n if (!cursor || loadingMore)\n return;\n const id = generation;\n loadingMore = true;\n error = null;\n emit();\n try {\n const page = await fetchComments({ referenceId, limit: pageSize, sort, cursor, currentMemberId });\n if (generation !== id)\n return;\n comments = mergeById(comments ?? [], page.comments);\n cursor = page.nextCursor;\n for (const [k, t] of Object.entries(page.replies))\n replies[k] ??= { ...t, loading: false };\n replies = { ...replies };\n }\n catch (e) {\n if (generation !== id)\n return;\n error = message(e);\n }\n finally {\n if (generation === id) {\n loadingMore = false;\n emit();\n }\n }\n },\n async loadReplies(topLevelId) {\n const thread = replies[topLevelId];\n if (!thread?.nextCursor || thread.loading)\n return;\n const id = generation;\n replies = { ...replies, [topLevelId]: { ...thread, loading: true } };\n emit();\n try {\n const page = await fetchReplies({ referenceId, topLevelId, cursor: thread.nextCursor, currentMemberId });\n if (generation !== id)\n return;\n replies = { ...replies, [topLevelId]: { comments: mergeById(thread.comments, page.comments), nextCursor: page.nextCursor, loading: false } };\n }\n catch (e) {\n if (generation !== id)\n return;\n replies = { ...replies, [topLevelId]: { ...thread, loading: false } };\n error = message(e);\n }\n emit();\n },\n setSort(next) {\n if (next === sort)\n return;\n sort = next;\n if (started)\n void load();\n },\n async create(text, { parentId = null, topLevelId = null } = {}) {\n const body = text.trim();\n if (!body || saving)\n return null;\n const threadId = parentId ? (topLevelId ?? parentId) : null;\n saving = true;\n needsSignIn = false;\n error = null;\n emit();\n try {\n const created = await createComment({ referenceId, text: body, parentId, topLevelId: threadId, currentMemberId });\n if (threadId) {\n // A reply joins its top-level comment's thread (oldest first → appended) and bumps its count.\n const thread = replies[threadId] ?? { comments: [], nextCursor: null, loading: false };\n replies = { ...replies, [threadId]: { ...thread, comments: mergeById(thread.comments, [created]) } };\n comments = (comments ?? []).map((c) => (c.id === threadId ? { ...c, replyCount: c.replyCount + 1 } : c));\n }\n else {\n comments = sort === \"NEWEST_FIRST\" ? mergeById([created], comments ?? []) : mergeById(comments ?? [], [created]);\n }\n return created;\n }\n catch (e) {\n if (isPermissionDenied(e))\n needsSignIn = true;\n else\n error = message(e);\n return null;\n }\n finally {\n saving = false;\n emit();\n }\n },\n async remove(commentId) {\n if (saving)\n return;\n saving = true;\n error = null;\n emit();\n try {\n await deleteComment(commentId);\n const top = (comments ?? []).find((c) => c.id === commentId);\n if (top) {\n // A top-level comment with replies stays as a placeholder so its thread keeps its context.\n const hasReplies = top.replyCount > 0 || (replies[commentId]?.comments.length ?? 0) > 0;\n comments = hasReplies ? comments.map((c) => (c.id === commentId ? toDeletedPlaceholder(c) : c)) : comments.filter((c) => c.id !== commentId);\n }\n else {\n for (const [k, t] of Object.entries(replies)) {\n if (!t.comments.some((c) => c.id === commentId))\n continue;\n // A reply that other replies point at stays as a placeholder; a leaf is dropped.\n const referenced = t.comments.some((c) => c.parentId === commentId);\n replies = { ...replies, [k]: { ...t, comments: referenced ? t.comments.map((c) => (c.id === commentId ? toDeletedPlaceholder(c) : c)) : t.comments.filter((c) => c.id !== commentId) } };\n if (!referenced)\n comments = (comments ?? []).map((c) => (c.id === k ? { ...c, replyCount: Math.max(0, c.replyCount - 1) } : c));\n }\n }\n }\n catch (e) {\n if (isPermissionDenied(e))\n needsSignIn = true;\n else\n error = message(e);\n }\n finally {\n saving = false;\n emit();\n }\n },\n };\n}\n",
23 "js/wix/comments.js": "// Declarations of comments.ts: what this file exports, their arguments and what they return.\n// import { type CreateCommentOptions, type ListCommentsOptions } from \"./comments-core.js\";\n// import type { BlogComment, CommentPage, CommentThread } from \"./blog-types.js\";\n// export { type CreateCommentOptions, type ListCommentsOptions };\n// export declare function fetchComments(o: ListCommentsOptions & {\n// currentMemberId?: string | null;\n// }): Promise<CommentPage>;\n// export declare function fetchReplies(o: {\n// referenceId: string;\n// topLevelId: string;\n// cursor: string;\n// currentMemberId?: string | null;\n// }): Promise<CommentThread>;\n// export declare function createComment(o: CreateCommentOptions & {\n// topLevelId?: string | null;\n// currentMemberId?: string | null;\n// }): Promise<BlogComment>;\n// export declare function deleteComment(commentId: string): Promise<void>;\n// Post comments over REST — the twin of app/wix/blog/comments.ts. Same exports, same DTOs; the\n// rules and mappers come from comments-core (the SAME file the SDK transport uses, deployed flat\n// next to this one). Every call runs as the calling identity — in a port, the viewer's session\n// token, never a process-wide one (a create would be attributed to whoever that token is).\n// docs: https://dev.wix.com/docs/api-reference/crm/community/comments/comments/list-comments-by-resource.md\n// docs: https://dev.wix.com/docs/api-reference/crm/community/comments/comments/create-comment.md\n// docs: https://dev.wix.com/docs/api-reference/crm/community/comments/comments/delete-comment.md\nimport { wixRequest } from \"./client.js\";\nimport { fetchAuthorsByIds } from \"./authors.js\";\nimport { commentAuthorIds, createCommentBody, listCommentsParams, listRepliesParams, rawCommentsOf, toComment, toCommentPage, toCommentThread, } from \"./comments-core.js\";\nconst COMMENTS = \"/comments/v1/comments\";\n/**\n * One page of top-level comments plus each one's reply seam (cursor, no replies yet).\n * GET /comments/v1/comments/list-by-resource?appId=&contextId=&resourceId=&commentSort.order=&replySort.order=OLDEST_FIRST&cursorPaging.limit=10&cursorPaging.repliesLimit=0[&cursorPaging.cursor=]\n */\nexport async function fetchComments(o) {\n const res = await wixRequest(`${COMMENTS}/list-by-resource`, { method: \"GET\", query: listCommentsParams(o) });\n const authors = await fetchAuthorsByIds(commentAuthorIds(rawCommentsOf(res)));\n return toCommentPage(res, { authors, currentMemberId: o.currentMemberId ?? null });\n}\n/**\n * One page of a top-level comment's replies, from the cursor its seam (or a previous page) handed back.\n * GET /comments/v1/comments/list-by-resource?appId=&contextId=&resourceId=&replySort.order=OLDEST_FIRST&cursorPaging.limit=10&cursorPaging.cursor=\n */\nexport async function fetchReplies(o) {\n const res = await wixRequest(`${COMMENTS}/list-by-resource`, { method: \"GET\", query: listRepliesParams(o) });\n const authors = await fetchAuthorsByIds(commentAuthorIds(rawCommentsOf(res)));\n return toCommentThread(res, { authors, currentMemberId: o.currentMemberId ?? null, topLevelId: o.topLevelId });\n}\n/**\n * Post a comment (or a reply, with `parentId`) as the current identity. Throws a WixApiError —\n * isPermissionDenied(e) means this viewer may not comment here (sign in).\n * POST /comments/v1/comments { comment: { appId, contextId, resourceId, content: { richContent }, parentComment?: { id } } }\n */\nexport async function createComment(o) {\n const res = await wixRequest(COMMENTS, { body: createCommentBody(o) });\n const raw = res?.comment ?? {};\n const authors = await fetchAuthorsByIds(commentAuthorIds([raw]));\n return toComment(raw, { authors, currentMemberId: o.currentMemberId ?? null, topLevelId: o.topLevelId ?? o.parentId ?? null });\n}\n/** Delete the current member's own comment. DELETE /comments/v1/comments/{commentId} */\nexport async function deleteComment(commentId) {\n await wixRequest(`${COMMENTS}/${encodeURIComponent(commentId)}`, { method: \"DELETE\" });\n}\n",
24 "js/wix/post-likes.js": "// Declarations of post-likes.ts: what this file exports, their arguments and what they return.\n// import type { LikeState } from \"./blog-types.js\";\n// export declare function likePost(postId: string): Promise<void>;\n// export declare function unlikePost(postId: string): Promise<void>;\n// export declare function fetchLikeState(postId: string): Promise<LikeState>;\n// Post likes over REST — the twin of app/wix/blog/post-likes.ts. Same exports, same DTOs; the FQDN,\n// query, and body come from posts-core. Every call runs as the calling identity (member or the\n// anonymous visitor the token stands for), so all three are CLIENT-ONLY — in a port, never from a\n// process-wide token. Porting: keep the paths, keep the bodies.\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/likes/create-like.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/likes/query-likes.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/likes/delete-like-by-fqdn-and-entity-id.md\nimport { wixRequest } from \"./client.js\";\nimport { fetchPostMetrics } from \"./posts.js\";\nimport { BLOG_POST_FQDN, likeBody, likeStateQuery } from \"./posts-core.js\";\n/** Like the post as the current viewer. POST /blog/v1/likes { like: { entityId, fqdn: \"wix.blog.v3.post\" } } */\nexport async function likePost(postId) {\n await wixRequest(\"/blog/v1/likes\", { body: likeBody(postId) });\n}\n/** Remove the current viewer's like. DELETE /blog/v1/likes/fqdn/{fqdn}/entity-id/{entityId} */\nexport async function unlikePost(postId) {\n await wixRequest(`/blog/v1/likes/fqdn/${encodeURIComponent(BLOG_POST_FQDN)}/entity-id/${encodeURIComponent(postId)}`, { method: \"DELETE\" });\n}\n/**\n * Whether THIS viewer liked the post (one row = yes; the service scopes the query to the caller),\n * plus the fresh counters — two independent reads (allSettled).\n * POST /blog/v1/likes/query { query: { filter: { fqdn: { $eq }, entityId: { $eq } }, cursorPaging: { limit: 1 } } }\n */\nexport async function fetchLikeState(postId) {\n const [likeRes, metrics] = await Promise.allSettled([\n wixRequest(\"/blog/v1/likes/query\", { body: { query: likeStateQuery(postId) } }),\n fetchPostMetrics(postId),\n ]);\n return {\n liked: likeRes.status === \"fulfilled\" && (likeRes.value?.likes ?? []).length > 0,\n metrics: metrics.status === \"fulfilled\" ? metrics.value : null,\n };\n}\n",
25 "js/wix/post-store.js": "// Declarations of post-store.ts: what this file exports, their arguments and what they return.\n// import type { BlogCategory, BlogTag, PostDetail, PostSummary } from \"./blog-types.js\";\n// export interface PostStoreOptions {\n// slug: string;\n// initialPost?: PostDetail;\n// initialCategories?: BlogCategory[];\n// initialTags?: BlogTag[];\n// initialRelated?: PostSummary[];\n// engagement?: boolean;\n// }\n// export interface PostState {\n// post: PostDetail | null;\n// notFound: boolean;\n// categories: BlogCategory[];\n// tags: BlogTag[];\n// related: PostSummary[] | null;\n// isLiked: boolean;\n// likeCount: number | undefined;\n// likeCountLoaded: boolean;\n// viewCount: number | undefined;\n// commentCount: number | undefined;\n// liking: boolean;\n// error: string | null;\n// }\n// export interface PostStore {\n// getState(): PostState;\n// subscribe(listener: (state: PostState) => void): () => void;\n// start(): void;\n// stop(): void;\n// toggleLike(): Promise<void>;\n// }\n// export declare function createPostStore({ slug, initialPost, initialCategories, initialTags, initialRelated, engagement }: PostStoreOptions): PostStore;\n// One post as a framework-free store — the logic behind usePost, usable from React (usePost wraps\n// it), from a static page's post view, from Vue/Svelte, or as the specification for a port. Loads\n// the post by slug (unless seeded), resolves its category chips from the catalog and its tag chips\n// BY ID (both in the post's own order), loads the related strip, and — in a browser only — the\n// viewer's like state with fresh counters, with an optimistic like toggle.\n//\n// SSR-friendly: pass the server-fetched PostDetail as `initialPost` (plus `initialCategories`,\n// `initialTags`, `initialRelated`) and no client read happens except the personal like state; a SPA\n// passes only the slug. One store per post surface: createPostStore().\nimport { fetchPostBySlug, fetchRelatedPosts } from \"./posts.js\";\nimport { fetchLikeState, likePost, unlikePost } from \"./post-likes.js\";\nimport { fetchBlogCategories, fetchTagsByIds, inIdOrder } from \"./taxonomy.js\";\nexport function createPostStore({ slug, initialPost, initialCategories, initialTags, initialRelated, engagement = true }) {\n let post = initialPost && initialPost.slug === slug ? initialPost : null;\n let notFound = false;\n let catalog = initialCategories ?? null;\n let tags = initialTags && post ? inIdOrder(post.tagIds, initialTags) : [];\n let related = initialRelated ?? null;\n let liked = false;\n let baselineLiked = false; // what the server said — the optimistic delta is measured against it\n let freshLikes;\n let likeCountLoaded = false;\n let viewCount = post?.viewCount;\n let commentCount = post?.commentCount;\n let liking = false;\n let error = null;\n let started = false;\n const listeners = new Set();\n let snapshot = null;\n const emit = () => { snapshot = null; for (const fn of listeners)\n fn(); };\n function likeCount() {\n const base = freshLikes ?? post?.likeCount;\n return base === undefined ? undefined : Math.max(0, base + Number(liked) - Number(baselineLiked));\n }\n function getState() {\n if (snapshot)\n return snapshot;\n snapshot = {\n post,\n notFound,\n categories: post && catalog ? inIdOrder(post.categoryIds, catalog) : [],\n tags,\n related,\n isLiked: liked,\n likeCount: likeCount(),\n likeCountLoaded,\n viewCount,\n commentCount,\n liking,\n error,\n };\n return snapshot;\n }\n // Everything that needs the post: tag chips by id, the related strip, the viewer's like state.\n function afterPost(p) {\n if (!initialTags)\n fetchTagsByIds(p.tagIds).then((t) => { if (started) {\n tags = t;\n emit();\n } });\n if (!initialRelated)\n fetchRelatedPosts(p).then((r) => { if (started) {\n related = r;\n emit();\n } });\n // Personal and visitor-scoped: never during SSR (the identity there is the app, not the viewer).\n if (engagement && typeof window !== \"undefined\") {\n fetchLikeState(p.id).then(({ liked: l, metrics }) => {\n if (!started)\n return;\n liked = baselineLiked = l;\n if (metrics) {\n freshLikes = metrics.likes;\n viewCount = metrics.views ?? viewCount;\n commentCount = metrics.comments ?? commentCount;\n }\n likeCountLoaded = true;\n emit();\n });\n }\n }\n return {\n getState,\n subscribe(fn) {\n const listener = () => { try {\n fn(getState());\n }\n catch (e) {\n console.error(\"post-store listener failed\", e);\n } };\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n start() {\n if (started)\n return;\n started = true;\n if (!catalog)\n fetchBlogCategories().then((c) => { if (started) {\n catalog = c;\n emit();\n } });\n if (post) {\n afterPost(post);\n }\n else {\n notFound = false;\n emit();\n fetchPostBySlug(slug)\n .then((p) => {\n if (!started)\n return;\n if (p) {\n post = p;\n viewCount = p.viewCount;\n commentCount = p.commentCount;\n afterPost(p);\n }\n else\n notFound = true;\n emit();\n })\n .catch((e) => { if (started) {\n error = e instanceof Error ? e.message : String(e);\n emit();\n } });\n }\n },\n stop() { started = false; },\n async toggleLike() {\n if (!post || liking)\n return;\n const next = !liked;\n liked = next;\n liking = true;\n emit(); // optimistic flip\n try {\n await (next ? likePost(post.id) : unlikePost(post.id));\n }\n catch {\n liked = !next; // roll back — the control reverting is the whole error surface\n }\n finally {\n liking = false;\n emit();\n }\n },\n };\n}\n",
26 "js/wix/posts-core.js": "// Declarations of posts-core.ts: what this file exports, their arguments and what they return.\n// import type { BlogAuthor, PostDetail, PostMetrics, PostSummary } from \"./blog-types.js\";\n// export type Raw = Record<string, any>;\n// export type ImgSrc = (value: any, width: number, height: number) => string;\n// export declare const BLOG_APP_ID = \"14bcded7-0066-7c35-14d7-466cb3f09103\";\n// export declare const BLOG_BASE = \"/blog\";\n// export declare const DETAIL_FIELDSETS: readonly [\"RICH_CONTENT\", \"CONTENT_TEXT\", \"SEO\", \"REFERENCE_ID\", \"INTERNAL_ID\"];\n// export declare const CARD_FIELDSETS: readonly [\"METRICS\"];\n// export declare const FEED_SORT: readonly [{\n// readonly fieldName: \"pinned\";\n// readonly order: \"DESC\";\n// }, {\n// readonly fieldName: \"firstPublishedDate\";\n// readonly order: \"DESC\";\n// }];\n// export declare const RECENT_SORT: readonly [{\n// readonly fieldName: \"firstPublishedDate\";\n// readonly order: \"DESC\";\n// }];\n// export declare const RELATED_LIMIT = 3;\n// export declare const BLOG_POST_FQDN = \"wix.blog.v3.post\";\n// export declare const SEO_DESCRIPTION_MAX = 500;\n// export declare const COVER_WIDTH = 1200;\n// export declare const COVER_HEIGHT = 675;\n// export declare const rawId: (raw: Raw | undefined | null) => string;\n// export declare const encodeSlugPath: (slug: string) => string;\n// export declare const postPath: (slug: string) => string;\n// export declare const categoryPath: (slug: string) => string;\n// export declare const tagPath: (slug: string) => string;\n// export declare function mediaValue(value: unknown): string;\n// export declare function videoPoster(value: unknown): string;\n// export declare function coverOf(raw: Raw, imgSrc: ImgSrc): {\n// coverUrl: string;\n// coverAlt: string;\n// };\n// export declare function dateParts(value: unknown): {\n// dateLabel: string;\n// dateISO: string;\n// };\n// export declare function relativeDateLabel(dateISO: string, now?: number, locale?: string): string;\n// export declare function formatCount(n: number | undefined, style?: \"compact\" | \"full\", locale?: string): string;\n// export declare function postMetaLine(post: Pick<PostSummary, \"dateLabel\" | \"minutesToRead\">): string;\n// export declare function seoTitleFrom(seoData: Raw | undefined | null): string;\n// export declare function seoDescriptionFrom(seoData: Raw | undefined | null): string;\n// export declare function toMetrics(raw: Raw | undefined | null): PostMetrics;\n// export declare function toSummary(raw: Raw, imgSrc: ImgSrc): PostSummary;\n// export declare function toDetail(raw: Raw, imgSrc: ImgSrc): PostDetail;\n// export declare function withAuthors<T extends PostSummary>(posts: T[], authors: Record<string, BlogAuthor>): T[];\n// export interface FetchPostsOptions {\n// limit?: number;\n// cursor?: string | null;\n// categoryId?: string | null;\n// tagId?: string | null;\n// }\n// export declare function feedQuery({ limit, cursor, categoryId, tagId }?: FetchPostsOptions): Raw;\n// export declare function slugQuery(slug: string): Raw;\n// export declare function byIdsQuery(ids: string[], limit?: number): Raw;\n// export declare function recentRelatedQuery(post: Pick<PostSummary, \"id\" | \"categoryIds\">, limit?: number): Raw;\n// export declare function pickCurated<T extends {\n// id: string;\n// }>(ids: string[], items: T[], currentId: string, limit?: number): T[];\n// export declare function likeStateQuery(postId: string): Raw;\n// export declare function likeBody(postId: string): Raw;\n// export declare function isNotFound(e: unknown): boolean;\n// export declare function isPermissionDenied(e: unknown): boolean;\n// Post rules and DTO mapping — transport-agnostic, imported by BOTH transports: ./posts.ts (the\n// SDK, managed Astro and React) and the REST twin in templates/blog/rest/posts.ts (fetch, a static\n// site or a port to another language). Every rule about dates, covers, the feed's sort and filters,\n// the body fieldsets, SEO overrides, related posts, likes, and URLs lives HERE, once. A raw post may\n// come from the SDK (`_id`, media as a `wix:image://` string, dates as Date) or from REST (`id`,\n// media as an { id, url } object, dates as ISO strings); the mappers accept both and produce the\n// same DTO. Its one runtime import is the shared Ricos renderer, deployed beside it on every stack.\nimport { richContentToHtml } from \"./rich-content.js\";\n/** The Wix Blog app id (Astro item-page routing; comments use it as appId). */\nexport const BLOG_APP_ID = \"14bcded7-0066-7c35-14d7-466cb3f09103\";\n/** Where the blog lives in the site's URL space; every path helper below builds on it. */\nexport const BLOG_BASE = \"/blog\";\n/**\n * The post page's fieldsets — without them richContent, contentText, seoData, and referenceId come\n * back undefined. SEO carries the owner's title/description overrides. The comments thread id is\n * `referenceId`: verified live, the REFERENCE_ID fieldset returns the post WITHOUT it (silently),\n * while INTERNAL_ID returns both `internalId` and `referenceId` — so both are requested, and the\n * mapper falls back to the post id (which is what referenceId equalled on every post read live, and\n * what Wix's own Blog widget addresses the thread by). (Fieldset enum: URL, CONTENT_TEXT, METRICS,\n * SEO, CONTACT_ID, RICH_CONTENT, REFERENCE_ID; INTERNAL_ID is accepted on the wire.)\n */\nexport const DETAIL_FIELDSETS = [\"RICH_CONTENT\", \"CONTENT_TEXT\", \"SEO\", \"REFERENCE_ID\", \"INTERNAL_ID\"];\n/**\n * The card fieldsets: METRICS puts view/like/comment counts on every post of a page in one read. The\n * field is deprecated on the SDK's Post type (\"data can be inconsistent\") but live on the wire; the\n * post page refreshes it with the dedicated metrics read (fetchPostMetrics / fetchLikeState).\n */\nexport const CARD_FIELDSETS = [\"METRICS\"];\n/**\n * The feed's order: pinned posts first, then newest first. Only listPosts' FEED default pins on its\n * own; queryPosts with an explicit sort is pure date order, so the pinned key is spelled out here.\n */\nexport const FEED_SORT = [\n { fieldName: \"pinned\", order: \"DESC\" },\n { fieldName: \"firstPublishedDate\", order: \"DESC\" },\n];\n/** Related posts: newest first among posts sharing a category (no pinned key — Wix's related strip has none). */\nexport const RECENT_SORT = [{ fieldName: \"firstPublishedDate\", order: \"DESC\" }];\n/** How many related posts a post page shows (Wix's default; curated lists are capped the same way). */\nexport const RELATED_LIMIT = 3;\n/** The Like service scopes likes by entity FQDN; blog posts use the V3 post FQDN. */\nexport const BLOG_POST_FQDN = \"wix.blog.v3.post\";\n/** Meta descriptions are cut here — the Blog API caps its own `excerpt` at the same length. */\nexport const SEO_DESCRIPTION_MAX = 500;\n/** Cover size every card and post header gets — 16:9, one scaled URL per post. */\nexport const COVER_WIDTH = 1200;\nexport const COVER_HEIGHT = 675;\nexport const rawId = (raw) => raw?._id ?? raw?.id ?? \"\";\n// ---- URLs ----------------------------------------------------------------------------------\n/** A slug may contain \"/\" — encode per segment so the path keeps its shape and a `[...slug]` route re-joins it. */\nexport const encodeSlugPath = (slug) => slug.split(\"/\").map(encodeURIComponent).join(\"/\");\n/** The post page: /blog/<slug>. Never build it from an id, never use `post.url` (the legacy blog page). */\nexport const postPath = (slug) => `${BLOG_BASE}/${encodeSlugPath(slug)}`;\n/** The category page: /blog/category/<slug>. */\nexport const categoryPath = (slug) => `${BLOG_BASE}/category/${encodeSlugPath(slug)}`;\n/** The tag page: /blog/tag/<slug>. */\nexport const tagPath = (slug) => `${BLOG_BASE}/tag/${encodeSlugPath(slug)}`;\n// ---- media ---------------------------------------------------------------------------------\n/**\n * A media value in the form imgSrc scales. The SDK hands over the `wix:image://v1/<file>/<name>#…`\n * string; REST hands over the Image object { id, url, width, height, filename }. Rebuilding the\n * wix:image form from the object makes both transports scale to the SAME URL — passing the object's\n * `url` through would skip scaling and the two paths would disagree on coverUrl.\n */\nexport function mediaValue(value) {\n if (!value)\n return \"\";\n if (typeof value === \"string\")\n return value;\n const o = value;\n const id = o._id ?? o.id;\n if (id && !String(id).startsWith(\"http\")) {\n const size = o.width && o.height ? `#originWidth=${o.width}&originHeight=${o.height}` : \"\";\n return `wix:image://v1/${id}/${o.filename ?? \"\"}${size}`;\n }\n return o.url ?? o.image ?? \"\";\n}\n/**\n * A video cover's poster as a wix:image value. The SDK's videoV2 is the string\n * `wix:video://v1/<id>/<file>#posterUri=<file>&posterWidth=<w>&posterHeight=<h>`; REST's is the\n * object { id, filename, posters: [{ id, url, width, height }] } (the SDK builds its string from\n * the LAST poster). \"\" when the video carries no poster.\n */\nexport function videoPoster(value) {\n if (!value)\n return \"\";\n if (typeof value === \"string\") {\n const hash = value.split(\"#\")[1] ?? \"\";\n const params = new URLSearchParams(hash);\n const uri = params.get(\"posterUri\");\n if (!uri)\n return \"\";\n const w = params.get(\"posterWidth\"), h = params.get(\"posterHeight\");\n return `wix:image://v1/${uri}/${uri}${w && h ? `#originWidth=${w}&originHeight=${h}` : \"\"}`;\n }\n const posters = value.posters ?? [];\n const poster = posters[posters.length - 1];\n if (!poster)\n return \"\";\n const id = poster.id || (poster.url ? String(poster.url).slice(String(poster.url).lastIndexOf(\"/\") + 1) : \"\");\n return id ? mediaValue({ id, filename: id, width: poster.width, height: poster.height }) : \"\";\n}\n/**\n * The cover as a card renders it. `media.displayed === false` is the author hiding the cover —\n * no cover at all. Otherwise the image wins, then a Wix video's poster, then an external embed's\n * thumbnail (YouTube/Vimeo). Alt text is the author's, else the title.\n */\nexport function coverOf(raw, imgSrc) {\n const media = raw.media;\n if (!media || media.displayed === false)\n return { coverUrl: \"\", coverAlt: \"\" };\n const coverUrl = imgSrc(mediaValue(media.wixMedia?.image), COVER_WIDTH, COVER_HEIGHT) ||\n imgSrc(videoPoster(media.wixMedia?.videoV2), COVER_WIDTH, COVER_HEIGHT) ||\n (media.embedMedia?.thumbnail?.url ?? \"\");\n return { coverUrl, coverAlt: coverUrl ? (media.altText ?? raw.title ?? \"\") : \"\" };\n}\n// ---- dates and numbers -----------------------------------------------------------------------\n/** Display date + ISO date from either a Date (SDK) or an ISO string (REST); both \"\" when missing or invalid. */\nexport function dateParts(value) {\n if (!value)\n return { dateLabel: \"\", dateISO: \"\" };\n const d = value instanceof Date ? value : new Date(String(value));\n if (Number.isNaN(d.getTime()))\n return { dateLabel: \"\", dateISO: \"\" };\n return {\n dateLabel: d.toLocaleDateString(undefined, { year: \"numeric\", month: \"short\", day: \"numeric\" }),\n dateISO: d.toISOString(),\n };\n}\nconst DAY_MS = 86_400_000;\n/**\n * Wix's default date style: relative under a week (\"3 days ago\"), \"Aug 5\" in the current year,\n * \"Aug 5, 2024\" otherwise. Pass the SAME `now` on the server and the client (e.g. a `fetchedAt`\n * you put in the island's props) or the two renders disagree and hydration warns. \"\" when unknown.\n */\nexport function relativeDateLabel(dateISO, now = Date.now(), locale) {\n if (!dateISO)\n return \"\";\n const d = new Date(dateISO);\n if (Number.isNaN(d.getTime()))\n return \"\";\n const diff = now - d.getTime();\n if (diff >= 0 && diff < 7 * DAY_MS) {\n const rtf = new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" });\n if (diff < 60_000)\n return rtf.format(-Math.round(diff / 1000), \"second\");\n if (diff < 3_600_000)\n return rtf.format(-Math.round(diff / 60_000), \"minute\");\n if (diff < DAY_MS)\n return rtf.format(-Math.round(diff / 3_600_000), \"hour\");\n return rtf.format(-Math.round(diff / DAY_MS), \"day\");\n }\n const sameYear = d.getFullYear() === new Date(now).getFullYear();\n return new Intl.DateTimeFormat(locale, { ...(sameYear ? {} : { year: \"numeric\" }), month: \"short\", day: \"numeric\" }).format(d);\n}\n/** \"1.2K\" (compact, the default) or \"1,234\" (full) — counters. \"\" for an unknown count. */\nexport function formatCount(n, style = \"compact\", locale) {\n if (typeof n !== \"number\" || !Number.isFinite(n))\n return \"\";\n return new Intl.NumberFormat(locale, style === \"compact\" ? { notation: \"compact\", maximumFractionDigits: 1 } : {}).format(n);\n}\n/** \"Aug 27, 2026 · 4 min read\" — the card's and header's meta line; either half alone, \"\" when neither is known. */\nexport function postMetaLine(post) {\n return [post.dateLabel, post.minutesToRead > 0 ? `${post.minutesToRead} min read` : \"\"].filter(Boolean).join(\" · \");\n}\n// ---- SEO overrides (the dashboard's Advanced SEO panel, on posts and categories) --------------\nconst liveSeoTags = (seoData) => (seoData?.tags ?? []).filter((t) => !t.disabled);\n/** The owner's title override, \"\" when none. A title tag carries its value in `children`. */\nexport function seoTitleFrom(seoData) {\n return liveSeoTags(seoData).find((t) => t.type === \"title\")?.children || \"\";\n}\n/** The owner's meta-description override, \"\" when none: a `meta` tag with props.name \"description\". */\nexport function seoDescriptionFrom(seoData) {\n const c = liveSeoTags(seoData).find((t) => t.type === \"meta\" && t.props?.name === \"description\")?.props?.content;\n return typeof c === \"string\" ? c : \"\";\n}\n// ---- mappers -------------------------------------------------------------------------------\nconst count = (v) => (typeof v === \"number\" ? v : undefined);\n/** The counters from a metrics object (a post's `metrics` field or the metrics read's `metrics`). */\nexport function toMetrics(raw) {\n return { views: count(raw?.views), likes: count(raw?.likes), comments: count(raw?.comments) };\n}\nexport function toSummary(raw, imgSrc) {\n const metrics = toMetrics(raw.metrics);\n return {\n id: rawId(raw),\n slug: raw.slug ?? \"\",\n title: raw.title ?? \"\",\n excerpt: raw.excerpt ?? \"\",\n ...dateParts(raw.firstPublishedDate),\n minutesToRead: raw.minutesToRead ?? 0,\n featured: raw.featured === true,\n pinned: raw.pinned === true,\n ...coverOf(raw, imgSrc),\n categoryIds: raw.categoryIds ?? [],\n tagIds: raw.tagIds ?? [],\n authorId: raw.memberId ?? \"\",\n authorName: \"\",\n authorAvatarUrl: \"\",\n viewCount: metrics.views,\n likeCount: metrics.likes,\n commentCount: metrics.comments,\n };\n}\nexport function toDetail(raw, imgSrc) {\n const summary = toSummary(raw, imgSrc);\n const contentText = String(raw.contentText ?? \"\");\n const updated = dateParts(raw.lastPublishedDate);\n return {\n ...summary,\n richContent: raw.richContent ?? null,\n bodyHtml: richContentToHtml(raw.richContent, imgSrc),\n // contentText is plain text — split on newlines for the fallback body.\n paragraphs: contentText\n .split(\"\\n\")\n .map((s) => s.trim())\n .filter(Boolean),\n updatedLabel: updated.dateLabel,\n updatedISO: updated.dateISO,\n seoTitle: seoTitleFrom(raw.seoData) || summary.title,\n seoDescription: (seoDescriptionFrom(raw.seoData) || summary.excerpt || contentText).slice(0, SEO_DESCRIPTION_MAX),\n relatedPostIds: raw.relatedPostIds ?? [],\n commentingEnabled: raw.commentingEnabled === true,\n referenceId: raw.referenceId ?? raw.internalId ?? raw.id ?? \"\",\n };\n}\n/** Join resolved authors onto posts; a post whose author did not resolve keeps \"\" (no byline). */\nexport function withAuthors(posts, authors) {\n return posts.map((p) => {\n const a = authors[p.authorId];\n return a ? { ...p, authorName: a.name, authorAvatarUrl: a.avatarUrl } : p;\n });\n}\n/**\n * The feed query as one object — the REST body's `query`, and the rule the SDK builder in ./posts.ts\n * spells with .descending()/.hasSome()/.skipTo(). A cursor encodes the original filter+sort, so a\n * cursor request carries ONLY cursorPaging; the first page carries the sort and the filters.\n */\nexport function feedQuery({ limit = 20, cursor, categoryId, tagId } = {}) {\n if (cursor)\n return { cursorPaging: { limit, cursor } };\n const filter = {};\n if (categoryId)\n filter.categoryIds = { $hasSome: [categoryId] };\n if (tagId)\n filter.tagIds = { $hasSome: [tagId] };\n return { ...(Object.keys(filter).length ? { filter } : {}), sort: FEED_SORT, cursorPaging: { limit } };\n}\n/** The by-slug query (REST body's `query`): exact slug, one row. A documented fallback — the by-slug getter is the primary read. */\nexport function slugQuery(slug) {\n return { filter: { slug: { $eq: slug } }, cursorPaging: { limit: 1 } };\n}\n/** Posts by id (REST body's `query`; the SDK spells it .in(\"_id\", ids)) — the curated related posts. */\nexport function byIdsQuery(ids, limit = ids.length) {\n return { filter: { id: { $in: ids } }, cursorPaging: { limit } };\n}\n/**\n * The algorithmic related posts: newest first among posts sharing a category with the current one,\n * the current one excluded. A post with no categories gets plain site-wide recents — a\n * `$hasSome: []` matches nothing, so the category filter is added only when there is one.\n */\nexport function recentRelatedQuery(post, limit = RELATED_LIMIT) {\n const filter = { id: { $ne: post.id } };\n if (post.categoryIds.length)\n filter.categoryIds = { $hasSome: post.categoryIds };\n return { filter, sort: RECENT_SORT, cursorPaging: { limit } };\n}\n/**\n * The curated list in the WRITER's order (the query answers in its own), the current post dropped,\n * ids that no longer resolve to a visible post dropped, capped at `limit`. Strictly either/or with\n * the algorithmic list: when at least one curated pick survives, exactly those are shown, never\n * topped up.\n */\nexport function pickCurated(ids, items, currentId, limit = RELATED_LIMIT) {\n const byId = new Map(items.map((p) => [p.id, p]));\n return ids\n .filter((id) => id !== currentId)\n .map((id) => byId.get(id))\n .filter((p) => p !== undefined)\n .slice(0, limit);\n}\n/**\n * \"Did THIS viewer like the post?\" — the Like service scopes queryLikes to the calling identity\n * (member or anonymous visitor), so one row means yes. REST body's `query`; the SDK spells it\n * .eq(\"fqdn\").eq(\"entityId\").limit(1). Personal → client-only, never during SSR.\n */\nexport function likeStateQuery(postId) {\n return { filter: { fqdn: { $eq: BLOG_POST_FQDN }, entityId: { $eq: postId } }, cursorPaging: { limit: 1 } };\n}\n/** The like write's body — the same object for createLike and for the REST POST. */\nexport function likeBody(postId) {\n return { like: { entityId: postId, fqdn: BLOG_POST_FQDN } };\n}\n/**\n * An expected miss, not a failure: a 404, or an application error whose code names NOT_FOUND.\n * Guarded with String() — the SDK's transformed error carries a NUMERIC code for HTTP failures.\n */\nexport function isNotFound(e) {\n const err = (e ?? {});\n return err.status === 404 || String(err.details?.applicationError?.code ?? err.code ?? \"\").includes(\"NOT_FOUND\");\n}\n/** A write the caller's identity may not perform — an anonymous visitor commenting where members only may. */\nexport function isPermissionDenied(e) {\n const err = (e ?? {});\n const code = String(err.details?.applicationError?.code ?? err.code ?? \"\");\n return err.status === 401 || err.status === 403 || /PERMISSION_DENIED|UNAUTHENTICATED|FORBIDDEN/.test(code);\n}\n",
27 "js/wix/posts.js": "// Declarations of posts.ts: what this file exports, their arguments and what they return.\n// import { BLOG_APP_ID, categoryPath, formatCount, postMetaLine, postPath, relativeDateLabel, tagPath, type FetchPostsOptions } from \"./posts-core.js\";\n// import type { PostDetail, PostMetrics, PostPage, PostSummary } from \"./blog-types.js\";\n// export { BLOG_APP_ID, categoryPath, formatCount, postMetaLine, postPath, relativeDateLabel, tagPath, type FetchPostsOptions };\n// export declare function fetchPosts(o?: FetchPostsOptions): Promise<PostPage>;\n// export declare function fetchPostBySlug(slug: string): Promise<PostDetail | null>;\n// export declare function fetchPostsByIds(ids: string[]): Promise<PostSummary[]>;\n// export declare function fetchRelatedPosts(post: Pick<PostDetail, \"id\" | \"categoryIds\" | \"relatedPostIds\">, limit?: number): Promise<PostSummary[]>;\n// export declare function fetchPostMetrics(postId: string): Promise<PostMetrics | null>;\n// Post reads over REST — the twin of app/wix/blog/posts.ts. Same exports, same DTOs; the rules and\n// mappers come from posts-core (the SAME file the SDK transport uses, deployed flat next to this one\n// by deploy.mjs --stack static), so this file is only the transport: one fetch with a literal body\n// per function. Every call here is safe from a browser with a visitor token — only PUBLISHED posts\n// come back to it. Porting: keep the paths, keep the bodies, port the core once.\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/posts-stats/query-posts.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/posts-stats/get-post-by-slug.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/posts-stats/get-post-metrics.md\nimport { wixRequest } from \"./client.js\";\nimport { imgSrc } from \"./media.js\";\nimport { fetchAuthorsByIds } from \"./authors.js\";\nimport { BLOG_APP_ID, CARD_FIELDSETS, DETAIL_FIELDSETS, RELATED_LIMIT, byIdsQuery, categoryPath, feedQuery, formatCount, isNotFound, pickCurated, postMetaLine, postPath, recentRelatedQuery, relativeDateLabel, tagPath, toDetail, toMetrics, toSummary, withAuthors, } from \"./posts-core.js\";\nexport { BLOG_APP_ID, categoryPath, formatCount, postMetaLine, postPath, relativeDateLabel, tagPath };\nconst POSTS_QUERY = \"/blog/v3/posts/query\";\n// The authors of one page in one batched read, joined onto the DTOs; a failed read leaves \"\" (no byline).\nasync function withPageAuthors(items) {\n return withAuthors(items, await fetchAuthorsByIds(items.map((p) => p.authorId)));\n}\n/**\n * One page of published posts, pinned first then newest first; `nextCursor` continues the same\n * query — send it alone, the filters ride inside it. Cards carry METRICS (counts).\n * POST /blog/v3/posts/query { fieldsets: [\"METRICS\"], query: { filter?, sort, cursorPaging: { limit } } } — first page\n * POST /blog/v3/posts/query { fieldsets: [\"METRICS\"], query: { cursorPaging: { limit, cursor } } } — later pages\n */\nexport async function fetchPosts(o = {}) {\n const res = await wixRequest(POSTS_QUERY, { body: { fieldsets: CARD_FIELDSETS, query: feedQuery(o) } });\n const paging = res?.pagingMetadata ?? {};\n return {\n posts: await withPageAuthors((res?.posts ?? []).map((p) => toSummary(p, imgSrc))),\n nextCursor: paging.hasNext ? (paging.cursors?.next ?? null) : null,\n };\n}\n/**\n * One post by URL slug with the full body and SEO/comments fields. Null when the slug resolves to\n * nothing (a 404 from this read) — a real 404 for the page, never a fallback to another post; any\n * other failure throws.\n * GET /blog/v3/posts/slugs/{slug}?fieldsets=RICH_CONTENT&fieldsets=CONTENT_TEXT&fieldsets=SEO&fieldsets=REFERENCE_ID&fieldsets=INTERNAL_ID\n * (INTERNAL_ID is the fieldset that carries referenceId; REFERENCE_ID alone returns the post without it)\n */\nexport async function fetchPostBySlug(slug) {\n let raw;\n try {\n raw = (await wixRequest(`/blog/v3/posts/slugs/${encodeURIComponent(slug)}`, { method: \"GET\", query: { fieldsets: DETAIL_FIELDSETS } }))?.post;\n }\n catch (e) {\n if (isNotFound(e))\n return null;\n throw e;\n }\n if (!raw)\n return null;\n const [detail] = await withPageAuthors([toDetail(raw, imgSrc)]);\n return detail;\n}\n/**\n * Posts by id, as cards (order is the API's — callers restore their own). Non-fatal: [] on failure.\n * POST /blog/v3/posts/query { fieldsets: [\"METRICS\"], query: { filter: { id: { $in } }, cursorPaging: { limit } } }\n */\nexport async function fetchPostsByIds(ids) {\n if (!ids.length)\n return [];\n try {\n const res = await wixRequest(POSTS_QUERY, { body: { fieldsets: CARD_FIELDSETS, query: byIdsQuery(ids) } });\n return withPageAuthors((res?.posts ?? []).map((p) => toSummary(p, imgSrc)));\n }\n catch {\n return [];\n }\n}\n/**\n * The post page's related strip: curated `relatedPostIds` (writer order) when at least one still\n * resolves, else the newest posts sharing a category, the current post excluded. Never topped up.\n * POST /blog/v3/posts/query { fieldsets: [\"METRICS\"], query: { filter: { id: { $ne }, categoryIds?: { $hasSome } }, sort, cursorPaging: { limit: 3 } } }\n */\nexport async function fetchRelatedPosts(post, limit = RELATED_LIMIT) {\n try {\n if (post.relatedPostIds.length) {\n const curated = pickCurated(post.relatedPostIds, await fetchPostsByIds(post.relatedPostIds), post.id, limit);\n if (curated.length)\n return curated;\n }\n const res = await wixRequest(POSTS_QUERY, { body: { fieldsets: CARD_FIELDSETS, query: recentRelatedQuery(post, limit) } });\n return withPageAuthors((res?.posts ?? []).map((p) => toSummary(p, imgSrc)));\n }\n catch {\n return [];\n }\n}\n/**\n * The post's fresh counters. Null on failure: keep the count you had.\n * GET /blog/v3/posts/{postId}/metrics → { metrics: { views, likes, comments } }\n */\nexport async function fetchPostMetrics(postId) {\n try {\n return toMetrics((await wixRequest(`/blog/v3/posts/${encodeURIComponent(postId)}/metrics`, { method: \"GET\" }))?.metrics);\n }\n catch {\n return null;\n }\n}\n",
28 "js/wix/taxonomy-core.js": "// Declarations of taxonomy-core.ts: what this file exports, their arguments and what they return.\n// import type { BlogCategory, BlogTag } from \"./blog-types.js\";\n// import type { ImgSrc, Raw } from \"./posts-core.js\";\n// export declare const CATEGORY_SORT: readonly [{\n// readonly fieldName: \"displayPosition\";\n// readonly order: \"ASC\";\n// }];\n// export declare const TAG_SORT: readonly [{\n// readonly fieldName: \"publishedPostCount\";\n// readonly order: \"DESC\";\n// }];\n// export declare const TAXONOMY_LIMIT = 100;\n// export declare const TAG_BY_IDS_LIMIT = 30;\n// export declare const RECENT_POSTS_LIMIT = 100;\n// export interface FetchCategoriesOptions {\n// withPosts?: boolean;\n// }\n// export declare function categoriesQuery(): Raw;\n// export declare function recentPostsQuery(): Raw;\n// export declare function withPostCounts(categories: BlogCategory[], recentPosts: Raw[], { withPosts }?: FetchCategoriesOptions): BlogCategory[];\n// export declare function tagsByIdsQuery(ids: string[]): Raw;\n// export declare function inIdOrder<T extends {\n// id: string;\n// }>(ids: readonly string[], items: readonly T[]): T[];\n// export declare function toCategory(raw: Raw, imgSrc: ImgSrc): BlogCategory;\n// export declare function toTag(raw: Raw): BlogTag;\n/** Menu order — displayPosition ascending; -1 (unplaced) sorts first, which is also what the SDK path shows. */\nexport const CATEGORY_SORT = [{ fieldName: \"displayPosition\", order: \"ASC\" }];\n/** Most-published-posts first. */\nexport const TAG_SORT = [{ fieldName: \"publishedPostCount\", order: \"DESC\" }];\n/** Blog taxonomies are small — one read of up to 100 is the whole list. */\nexport const TAXONOMY_LIMIT = 100;\n/** A post carries at most 30 tag ids (the API's own cap), so one by-id read answers a post in full. */\nexport const TAG_BY_IDS_LIMIT = 30;\n/**\n * The latest posts read next to the categories. A category's `postCount` is recounted by Wix after each\n * publish and can stay short of the real number when posts are published close together (0 for a\n * category that has posts), so the counts are checked against the posts themselves.\n */\nexport const RECENT_POSTS_LIMIT = 100;\nconst rawId = (raw) => raw?._id ?? raw?.id ?? \"\";\n// A copy of posts-core's mediaValue: the REST Image object → the wix:image form imgSrc scales.\nfunction mediaValue(value) {\n if (!value)\n return \"\";\n if (typeof value === \"string\")\n return value;\n const o = value;\n if (o.id && !String(o.id).startsWith(\"http\")) {\n const size = o.width && o.height ? `#originWidth=${o.width}&originHeight=${o.height}` : \"\";\n return `wix:image://v1/${o.id}/${o.filename ?? \"\"}${size}`;\n }\n return o.url ?? o.image ?? \"\";\n}\n/** The categories query (REST body's `query`); the SDK spells it .ascending(\"displayPosition\").limit(100). */\nexport function categoriesQuery() {\n return { sort: CATEGORY_SORT, paging: { limit: TAXONOMY_LIMIT } };\n}\n/** The latest posts' query (REST body's `query`; the SDK spells it .descending(\"firstPublishedDate\").limit(100)). Base fields carry `categoryIds`. */\nexport function recentPostsQuery() {\n return { sort: [{ fieldName: \"firstPublishedDate\", order: \"DESC\" }], cursorPaging: { limit: RECENT_POSTS_LIMIT } };\n}\n/**\n * Each category's postCount raised to the number of the latest posts that carry it, then, with\n * `withPosts`, the categories that have none dropped. Wix's count can only be short here, never the\n * posts: a category that a recent post carries has posts.\n */\nexport function withPostCounts(categories, recentPosts, { withPosts = false } = {}) {\n const seen = new Map();\n for (const post of recentPosts)\n for (const id of (post.categoryIds ?? []))\n seen.set(id, (seen.get(id) ?? 0) + 1);\n const counted = categories.map((c) => ((seen.get(c.id) ?? 0) > c.postCount ? { ...c, postCount: seen.get(c.id) } : c));\n return withPosts ? counted.filter((c) => c.postCount > 0) : counted;\n}\n/**\n * Tags BY ID (REST body's `query`; the SDK spells it .in(\"_id\", ids).limit(30)) — a post's chips are\n * read this way, not joined off a top-100 catalog: a blog's tags run to hundreds, and a tag past the\n * first page would silently lose its chip. Categories run to tens, so their catalog is joined in memory.\n */\nexport function tagsByIdsQuery(ids) {\n return { filter: { id: { $in: ids } }, paging: { limit: TAG_BY_IDS_LIMIT } };\n}\n/**\n * Items in the order the POST lists them (its `categoryIds`/`tagIds`), ids that resolved to nothing\n * dropped — the catalog's displayPosition and the query's own order are not the post's order.\n */\nexport function inIdOrder(ids, items) {\n const byId = new Map(items.map((x) => [x.id, x]));\n return ids.map((id) => byId.get(id)).filter((x) => x !== undefined);\n}\nexport function toCategory(raw, imgSrc) {\n return {\n id: rawId(raw),\n slug: raw.slug ?? \"\",\n label: raw.label ?? \"\", // the API's display name is `label`, never `name`\n title: raw.title ?? \"\", // the category's SEO title (the dashboard's \"title tag\"); \"\" until the owner sets one\n description: raw.description ?? \"\",\n postCount: raw.postCount ?? 0, // Wix's count, which can be short: the fetchers correct it from the posts (withPostCounts)\n coverUrl: imgSrc(mediaValue(raw.coverImage), 1200, 675),\n };\n}\nexport function toTag(raw) {\n return {\n id: rawId(raw),\n slug: raw.slug ?? \"\",\n label: raw.label ?? \"\",\n postCount: raw.publishedPostCount ?? 0, // PUBLISHED posts — postCount also counts drafts\n };\n}\n",
29 "js/wix/taxonomy.js": "// Declarations of taxonomy.ts: what this file exports, their arguments and what they return.\n// import { inIdOrder, type FetchCategoriesOptions } from \"./taxonomy-core.js\";\n// import type { BlogCategory, BlogTag } from \"./blog-types.js\";\n// export { inIdOrder, type FetchCategoriesOptions };\n// export declare function fetchBlogCategories(o?: FetchCategoriesOptions): Promise<BlogCategory[]>;\n// export declare function fetchBlogTags(): Promise<BlogTag[]>;\n// export declare function fetchTagsByIds(ids: readonly string[]): Promise<BlogTag[]>;\n// export declare function fetchCategoryBySlug(slug: string): Promise<BlogCategory | null>;\n// export declare function fetchTagBySlug(slug: string): Promise<BlogTag | null>;\n// Category + tag reads over REST — the twin of app/wix/blog/taxonomy.ts. Same exports, same DTOs;\n// the mappers come from taxonomy-core (the SAME file the SDK transport uses, deployed flat next to\n// this one). Taxonomy queries page by OFFSET (`paging`), unlike posts (`cursorPaging`); a blog's\n// taxonomy fits in one read of 100. All calls are visitor-safe.\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/category/query-categories.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/category/get-category-by-slug.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/tags/query-tags.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/tags/get-tag-by-slug.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/posts-stats/query-posts.md\nimport { wixRequest } from \"./client.js\";\nimport { imgSrc } from \"./media.js\";\nimport { TAG_SORT, TAXONOMY_LIMIT, categoriesQuery, inIdOrder, recentPostsQuery, tagsByIdsQuery, toCategory, toTag, withPostCounts } from \"./taxonomy-core.js\";\nexport { inIdOrder };\n/**\n * Categories in menu order, each postCount checked against the latest posts (withPostCounts);\n * `withPosts` drops empty ones. Non-fatal: [] on failure (the feed renders without its filter row);\n * without the posts, Wix's counts.\n * POST /blog/v3/categories/query { query: { sort: [{ fieldName: \"displayPosition\", order: \"ASC\" }], paging: { limit: 100 } } }\n * POST /blog/v3/posts/query { query: { sort: [{ fieldName: \"firstPublishedDate\", order: \"DESC\" }], cursorPaging: { limit: 100 } } }\n */\nexport async function fetchBlogCategories(o = {}) {\n try {\n const [res, recent] = await Promise.all([\n wixRequest(\"/blog/v3/categories/query\", { body: { query: categoriesQuery() } }),\n wixRequest(\"/blog/v3/posts/query\", { body: { query: recentPostsQuery() } }).catch(() => null),\n ]);\n const list = (res?.categories ?? []).map((c) => toCategory(c, imgSrc)).filter((c) => c.id);\n return withPostCounts(list, (recent?.posts ?? []), o);\n }\n catch {\n return [];\n }\n}\n/**\n * Tags, most-published-posts first — a tag cloud. Non-fatal: [] on failure.\n * POST /blog/v3/tags/query { query: { sort: [{ fieldName: \"publishedPostCount\", order: \"DESC\" }], paging: { limit: 100 } } }\n */\nexport async function fetchBlogTags() {\n try {\n const res = await wixRequest(\"/blog/v3/tags/query\", { body: { query: { sort: TAG_SORT, paging: { limit: TAXONOMY_LIMIT } } } });\n return (res?.tags ?? []).map(toTag).filter((t) => t.id);\n }\n catch {\n return [];\n }\n}\n/**\n * A post's tags by id, in the post's own order. Non-fatal: [] on failure.\n * POST /blog/v3/tags/query { query: { filter: { id: { $in: ids } }, paging: { limit: 30 } } }\n */\nexport async function fetchTagsByIds(ids) {\n if (!ids.length)\n return [];\n try {\n const res = await wixRequest(\"/blog/v3/tags/query\", { body: { query: tagsByIdsQuery([...ids]) } });\n return inIdOrder(ids, (res?.tags ?? []).map(toTag).filter((t) => t.id));\n }\n catch {\n return [];\n }\n}\n// The two by-slug getters answer in DIFFERENT envelopes ({ category } and { tag }); a missing slug\n// is a 404 — mapped to null here, as the SDK transport does.\n/** One category by its URL slug. Null when not found. GET /blog/v3/categories/slugs/{slug} */\nexport async function fetchCategoryBySlug(slug) {\n try {\n const res = await wixRequest(`/blog/v3/categories/slugs/${encodeURIComponent(slug)}`, { method: \"GET\" });\n return res?.category ? toCategory(res.category, imgSrc) : null;\n }\n catch {\n return null;\n }\n}\n/** One tag by its URL slug. Null when not found. GET /blog/v3/tags/slugs/{slug} */\nexport async function fetchTagBySlug(slug) {\n try {\n const res = await wixRequest(`/blog/v3/tags/slugs/${encodeURIComponent(slug)}`, { method: \"GET\" });\n return res?.tag ? toTag(res.tag) : null;\n }\n catch {\n return null;\n }\n}\n"