Setting the file. One moment.
Seed Blog · Wix Vibe Headless · wix/skills · Skills Docs
ContentsBack to the top of the page 22.10
Post Detail
async function createCategories
— line 172
This file
Number 22.14
Position 14 of 145
Type JavaScript
Size 14 KB
Lines 269 references/blog/seed/ seed-blog.cjs
JavaScript · 269 lines · 14 KB
// const posts = await seed.createPosts(ctx, [
14 // { title: "How We Roast Our Beans", categoryIds: [cats[0].id], content: [
15 // { type: "heading", text: "From farm to cup", level: 2 },
16 // { type: "paragraph", text: "Every batch starts with beans from a single estate." },
17 // { type: "quote", text: "Great coffee is grown, not made." },
18 // ] },
19 // ], { memberId });
20 // // optional — import each image url to Wix Media (blog binds by file id), then attach covers + re-publish
21 // const files = await Promise.all(imageUrls.map((u) => seed.importImage(ctx, u))); // → [{ id, url }]
22 // await seed.attachPostCovers(ctx, posts.map((p, i) => ({ postId: p.id, fileId: files[i].id })));
23 //
24 // Live-verified end-to-end (members author, categories, posts single+bulk, tags, covers, idempotent
25 // re-runs). If any call ever fails with a shape the caller didn't expect, fall back to the documentation skill available in your environment (search + read the live Wix Blog API reference) — never guess. Source recipe:
26 // wix-headless/references/inline-recipes/setup-blog.md.
27
28 const API = "https://www.wixapis.com" ;
29 const BLOG_APP_ID = "14bcded7-0066-7c35-14d7-466cb3f09103" ; // installBlogApp installs this before seeding
30
31 async function req ( ctx , path , { method = "POST" , body } = {}) {
32 const res = await fetch ( API + path, {
33 method,
34 headers: {
35 Authorization: `Bearer ${ ctx . token }` ,
36 "wix-site-id" : ctx.siteId,
37 "Content-Type" : "application/json" ,
38 },
39 body: body ? JSON . stringify (body) : undefined ,
40 });
41 const json = await res. json (). catch (() => ({}));
42 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
43 return json;
44 }
45
46 // ---- Ricos richContent builder (per recipe § "CRITICAL RICOS NESTING") ----
47 // A post's `content` is a list of plain block descriptors; this builds the Ricos node tree.
48 // Rules baked in: TEXT is always a leaf inside a container; BLOCKQUOTE / LIST_ITEM wrap a
49 // PARAGRAPH; BULLETED_LIST / ORDERED_LIST wrap LIST_ITEM -> PARAGRAPH -> TEXT; every container
50 // node gets a unique id, TEXT leaves use id "". Supported block types: heading, paragraph,
51 // quote, bulleted, ordered. For node types the recipe doesn't spell out (CODE_BLOCK, IMAGE, …)
52 // pass a pre-built `richContent` on the post instead and it's used verbatim (see fall-back below).
53 const mkText = ( text ) => ({ type: "TEXT" , id: "" , nodes: [], textData: { text: text || "" , decorations: [] } });
54 const mkParagraph = ( id , text ) => ({ type: "PARAGRAPH" , id, nodes: [ mkText (text)], paragraphData: {} });
55
56 function mkRichContent ( blocks = [], postIdx = 0 ) {
57 let n = 0 ;
58 const id = () => `p${ postIdx }-n${ n ++ }` ;
59 const nodes = [];
60 for ( const b of blocks) {
61 switch (b.type) {
62 case "heading" :
63 nodes. push ({ type: "HEADING" , id: id (), nodes: [ mkText (b.text)], headingData: { level: b.level ?? 2 } });
64 break ;
65 case "quote" :
66 // BLOCKQUOTE wraps a PARAGRAPH, per recipe
67 nodes. push ({ type: "BLOCKQUOTE" , id: id (), nodes: [ mkParagraph ( id (), b.text)], blockquoteData: { indentation: 1 } });
68 break ;
69 case "bulleted" :
70 case "ordered" : {
71 // LIST -> LIST_ITEM -> PARAGRAPH -> TEXT, per recipe
72 const listType = b.type === "bulleted" ? "BULLETED_LIST" : "ORDERED_LIST" ;
73 nodes. push ({
74 type: listType, id: id (),
75 nodes: (b.items ?? []). map (( item ) => ({ type: "LIST_ITEM" , id: id (), nodes: [ mkParagraph ( id (), item)] })),
76 });
77 break ;
78 }
79 case "paragraph" :
80 default :
81 nodes. push ( mkParagraph ( id (), b.text));
82 }
83 }
84 return { nodes };
85 }
86
87 // One post's plain data -> a flat Blog V3 draft-post object.
88 // `content` (block list) is turned into Ricos richContent unless a pre-built `richContent` is given.
89 // media is omitted here — covers are a separate pass (attachPostCovers), optional (a separate covers pass). Per recipe.
90 function buildPost ( p , i , memberId ) {
91 return {
92 title: p.title,
93 memberId,
94 richContent: p.richContent ?? mkRichContent (p.content, i),
95 ... (p.categoryIds ? { categoryIds: p.categoryIds } : {}),
96 ... (p.tagIds ? { tagIds: p.tagIds } : {}),
97 };
98 }
99
100 // ---- exported operations ----
101
102 // STEP 1: fetch a real author memberId (required — every post create needs it; a fabricated id
103 // fails with "memberIds ... do not exist"). Returns members[0].id; throws loudly if none exist.
104 async function getAuthorMemberId ( ctx ) {
105 const r = await req (ctx, "/members/v1/members?fieldsets=PUBLIC&paging.limit=1" , { method: "GET" });
106 const id = r.members?.[ 0 ]?.id;
107 if ( ! id) throw new Error ( `No site member found for author attribution: ${ JSON . stringify ( r ). slice ( 0 , 400 ) }` );
108 return id;
109 }
110
111 /**
112 * STEP 2: create posts, published (publish:true so they go live immediately).
113 * Auto-selects the endpoint per recipe: single-post endpoint for exactly one post (nested
114 * `{ draftPost }` envelope), the bulk endpoint for >= 2 (flat per-item objects, NOT wrapped).
115 * @param posts [{ title, content: [blocks] | richContent?, categoryIds?, tagIds? }]
116 * content blocks: { type:"heading", text, level? } | { type:"paragraph", text }
117 * | { type:"quote", text } | { type:"bulleted"|"ordered", items:[text,...] }.
118 * Pass a pre-built Ricos `richContent` instead of `content` for node types not covered here.
119 * @param opts { memberId } memberId from getAuthorMemberId — required; publish defaults to true.
120 * @returns [{ id, index, success }] (id is the draftPostId; feeds attachPostCovers)
121 */
122 async function createPosts ( ctx , posts , { memberId , publish = true } = {}) {
123 if ( ! memberId) throw new Error ( "createPosts requires opts.memberId (see getAuthorMemberId)" );
124 if (posts. length === 1 ) {
125 // single-post endpoint uses the nested { draftPost } envelope
126 const r = await req (ctx, "/blog/v3/draft-posts" , { body: { draftPost: buildPost (posts[ 0 ], 0 , memberId), publish } });
127 return [{ id: r.draftPost?.id, index: 0 , success: !! r.draftPost?.id }];
128 }
129 // bulk endpoint: `bulk` is a path segment BETWEEN v3 and draft-posts; each item is FLAT (no draftPost wrapper)
130 const r = await req (ctx, "/blog/v3/bulk/draft-posts/create" , {
131 body: { draftPosts: posts. map (( p , i ) => buildPost (p, i, memberId)), publish },
132 });
133 // Bulk returns 200 even on partial failure — read per-item results[].itemMetadata.success.
134 return (r.results ?? []). map (( x ) => ({
135 id: x.itemMetadata?.id, index: x.itemMetadata?.originalIndex, success: !! x.itemMetadata?.success,
136 }));
137 }
138
139 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
140
141 // Existing label -> id, straight from the query (the source of truth for what actually persisted).
142 async function labelIdMap ( ctx , kind ) {
143 const path = kind === "categories" ? "/blog/v3/categories/query" : "/v3/tags/query" ;
144 const r = await req (ctx, path, { body: { query: { paging: { limit: 100 } } } });
145 return new Map ((r[kind] ?? []). map (( x ) => [x.label, x.id]));
146 }
147
148 // Create category/tag labels resiliently. TWO hazards this absorbs:
149 // 1. Fresh-install provisioning window — for a few seconds after the Blog app is installed, per-item
150 // category/tag creates return 200 with an id but DON'T persist (last-write-wins; the id is a lie).
151 // Posts (bulk) are unaffected. So we never trust the create response — we re-query and treat what
152 // the query returns as truth, re-creating anything still missing until it sticks (store warms in ~s).
153 // 2. Idempotency — an already-present label (e.g. a partial-failure re-run) is skipped, not re-created.
154 // Category bodies are NESTED (`{ category: { label } }`); tag bodies are FLAT (`{ label }`) — a
155 // `{ tag: { label } }` body sends an empty top-level label and 400s. Returns [{ id, name }].
156 async function ensureLabels ( ctx , kind , createPath , mkBody , names ) {
157 let map = await labelIdMap (ctx, kind);
158 for ( let attempt = 0 ; attempt < 8 ; attempt ++ ) {
159 const missing = names. filter (( n ) => ! map. has (n));
160 if ( ! missing. length ) break ;
161 if (attempt) await sleep ( 1500 ); // backoff only between retries — happy path pays nothing
162 for ( const name of missing) {
163 try { await req (ctx, createPath, { body: mkBody (name) }); }
164 catch (e) { if ( ! String (e.message). includes ( "-> 409" )) throw e; } // 409 = raced, already there
165 }
166 map = await labelIdMap (ctx, kind);
167 }
168 return names. map (( name ) => ({ id: map. get (name), name }));
169 }
170
171 // STEP 3 (optional — only if the request groups posts): create categories. No bulk endpoint.
172 async function createCategories ( ctx , names ) {
173 return ensureLabels (ctx, "categories" , "/blog/v3/categories" , ( name ) => ({ category: { label: name } }), names);
174 }
175
176 // STEP 3 (optional): create tags. Feed the returned ids into post.tagIds.
177 async function createTags ( ctx , names ) {
178 return ensureLabels (ctx, "tags" , "/blog/v3/tags" , ( name ) => ({ label: name }), names);
179 }
180
181 // Import an external image URL into Wix Media → { id, url }. Blog binds the cover by the Wix Media
182 // file **id**, NOT a url — an external url (e.g. a base44 generate_image result) MUST be imported
183 // first; the raw url renders nothing. id = wixstatic file id, url = the permanent wixstatic url.
184 async function importImage ( ctx , url , displayName = "image.png" ) {
185 const r = await req (ctx, "/site-media/v1/files/import" , { body: { url, mimeType: "image/png" , displayName } });
186 const f = r.file || r;
187 if ( ! f?.id) throw new Error ( `import-file returned no file id: ${ JSON . stringify ( r ). slice ( 0 , 200 ) }` );
188 return { id: f.id, url: f.url };
189 }
190
191 // Attach images step (optional). covers: [{ postId, fileId }] where fileId is the WixMedia
192 // file.id from importImage (Blog binds the cover by id, not url). Per post:
193 // PATCH /blog/v3/draft-posts/{id} (NOT POST …/{id}/update — that 404s for a single post),
194 // setting media.displayed:true + media.custom:true + wixMedia.image.id (id ALONE is a silent no-op),
195 // then re-publish (the PATCH sets hasUnpublishedChanges, so the live post stays cover-less until republish).
196 // Image failures never block the run — skip a failed cover, leave the post text-only.
197 async function attachPostCovers ( ctx , covers ) {
198 for ( const { postId , fileId } of covers) {
199 try {
200 await req (ctx, `/blog/v3/draft-posts/${ postId }` , {
201 method: "PATCH" ,
202 body: { draftPost: { media: { displayed: true , custom: true , wixMedia: { image: { id: fileId } } } } },
203 });
204 await req (ctx, `/blog/v3/draft-posts/${ postId }/publish` , { method: "POST" });
205 } catch (e) {
206 console. warn ( `cover attach skipped for post ${ postId }: ${ e . message }` );
207 }
208 }
209 }
210
211 /**
212 * DEFAULT one-call path — seed a whole blog in ONE exec call. Resolves the author memberId and
213 * category/tag names → ids internally and keeps every id in memory, so nothing is hand-threaded
214 * across exec calls. Order: memberId → categories/tags → posts (with resolved ids) → covers.
215 * @param plan {
216 * posts: [{ title, content?|richContent?, category?|categories?(name|names), tags?(names), coverImageUrl? }],
217 * categories?: [name], // pre-create categories even if no post references them
218 * tags?: [name],
219 * }
220 * category/categories/tags are display NAMES (resolved to ids here); coverImageUrl is a plain image
221 * url — imported to Wix Media here — and a cover is attached only for posts that provide one.
222 * @returns { posts:[{id,index,success}], categories:[{id,name}], tags:[{id,name}], coversAttached }
223 */
224 // Install the Wix Blog app before seeding — base44 sites aren't guaranteed to have it (no separate
225 // Setup step here, unlike the wix-headless recipe). Idempotent: re-installing returns 200.
226 async function installBlogApp ( ctx ) {
227 return req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
228 tenant: { tenantType: "SITE" , id: ctx.siteId },
229 appInstance: { appDefId: BLOG_APP_ID , enabled: true },
230 } });
231 }
232
233 async function setupBlog ( ctx , { posts = [], categories = [], tags = [] } = {}) {
234 await installBlogApp (ctx);
235 await sleep ( 3000 ); // let a fresh Blog install settle so the first category/tag writes stick (see ensureLabels);
236 // correctness is still guaranteed by ensureLabels' verify-retry — this just cuts the retries.
237 const memberId = await getAuthorMemberId (ctx);
238 const catNames = [ ...new Set ([ ... categories, ... posts. flatMap (( p ) => []. concat (p.category ?? [], p.categories ?? []))])];
239 const tagNames = [ ...new Set ([ ... tags, ... posts. flatMap (( p ) => []. concat (p.tags ?? []))])];
240 const cats = catNames. length ? await createCategories (ctx, catNames) : [];
241 const tgs = tagNames. length ? await createTags (ctx, tagNames) : [];
242 const catId = new Map (cats. map (( c ) => [c.name, c.id]));
243 const tagId = new Map (tgs. map (( t ) => [t.name, t.id]));
244 const created = await createPosts (ctx, posts. map (( p ) => {
245 const cn = []. concat (p.category ?? [], p.categories ?? []);
246 const tn = []. concat (p.tags ?? []);
247 return {
248 ... p,
249 ... (cn. length ? { categoryIds: cn. map (( n ) => catId. get (n)). filter (Boolean) } : {}),
250 ... (tn. length ? { tagIds: tn. map (( n ) => tagId. get (n)). filter (Boolean) } : {}),
251 };
252 }), { memberId });
253 const covers = [];
254 for ( let i = 0 ; i < created. length ; i ++ ) {
255 const url = posts[i]?.coverImageUrl;
256 if ( ! created[i]?.id || ! url) continue ;
257 try {
258 const file = await importImage (ctx, url, `post-${ i }.png` ); // → Wix Media file id
259 covers. push ({ postId: created[i].id, fileId: file.id });
260 } catch { /* never block on image failure — leave the post cover-less */ }
261 }
262 if (covers. length ) await attachPostCovers (ctx, covers);
263 return { posts: created, categories: cats, tags: tgs, coversAttached: covers. length };
264 }
265
266 module . exports = {
267 setupBlog, installBlogApp,
268 getAuthorMemberId, createPosts, createCategories, createTags, importImage, attachPostCovers,
269 };