Setting the file. One moment.
Seed Blog · Wix Headless Fast · wix/skills · Skills Docs
ContentsBack to the top of the page async function labelIdMap
— line 157
This file
Number 7.13
Position 13 of 148
Type JavaScript
Size 14 KB
Lines 287 references/blog/seed/ seed-blog.mjs
JavaScript · 287 lines · 14 KB
15 // "tags"?, "coverImageUrl"? | "coverImagePrompt"? }] }
16 // content blocks: { type:"heading", text, level? } | { type:"paragraph", text }
17 // | { type:"quote", text } | { type:"bulleted"|"ordered", items:[text,…] }
18 //
19 // Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
20 // read the live API reference; authoritative source recipe:
21 // wix-headless/references/inline-recipes/setup-blog.md.
22 import { execFileSync } from "node:child_process" ;
23 import { readFileSync } from "node:fs" ;
24 import { resolveItemImages } from "../../shared/seed/images.mjs" ;
25
26 const API = "https://www.wixapis.com" ;
27 const BLOG_APP_ID = "14bcded7-0066-7c35-14d7-466cb3f09103" ;
28
29 export function makeCtx ({ cwd = process. cwd () } = {}) {
30 const config = JSON . parse ( readFileSync ( `${ cwd }/wix.config.json` , "utf8" ));
31 const siteId = config.siteId ?? config.projectId;
32 if ( ! siteId) throw new Error ( "wix.config.json has no siteId — is this a Wix CLI project?" );
33 const token = execFileSync ( "npx" , [ "@wix/cli@latest" , "token" , "--site" , siteId], {
34 encoding: "utf8" ,
35 cwd,
36 }). trim ();
37 if ( ! token) throw new Error ( "The Wix CLI returned no token — run `npx @wix/cli@latest login` first." );
38 return { token, siteId };
39 }
40
41 async function req ( ctx , path , { method = "POST" , body } = {}) {
42 const res = await fetch ( API + path, {
43 method,
44 headers: {
45 Authorization: `Bearer ${ ctx . token }` ,
46 "wix-site-id" : ctx.siteId,
47 "Content-Type" : "application/json" ,
48 },
49 body: body ? JSON . stringify (body) : undefined ,
50 });
51 const json = await res. json (). catch (() => ({}));
52 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
53 return json;
54 }
55
56 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
57
58 // ---- Ricos richContent builder (setup-blog.md § "CRITICAL RICOS NESTING") --------------------
59 // Rules baked in: TEXT is always a leaf inside a container; BLOCKQUOTE / LIST_ITEM wrap a
60 // PARAGRAPH; BULLETED_LIST / ORDERED_LIST wrap LIST_ITEM -> PARAGRAPH -> TEXT; every container
61 // node gets a unique id, TEXT leaves use id "". For node types not covered here (code, images)
62 // pass a pre-built `richContent` on the post instead — it's used verbatim.
63 const mkText = ( text ) => ({ type: "TEXT" , id: "" , nodes: [], textData: { text: text || "" , decorations: [] } });
64 const mkParagraph = ( id , text ) => ({ type: "PARAGRAPH" , id, nodes: [ mkText (text)], paragraphData: {} });
65
66 function mkRichContent ( blocks = [], postIdx = 0 ) {
67 let n = 0 ;
68 const id = () => `p${ postIdx }-n${ n ++ }` ;
69 const nodes = [];
70 for ( const b of blocks) {
71 switch (b.type) {
72 case "heading" :
73 nodes. push ({ type: "HEADING" , id: id (), nodes: [ mkText (b.text)], headingData: { level: b.level ?? 2 } });
74 break ;
75 case "quote" :
76 nodes. push ({ type: "BLOCKQUOTE" , id: id (), nodes: [ mkParagraph ( id (), b.text)], blockquoteData: { indentation: 1 } });
77 break ;
78 case "bulleted" :
79 case "ordered" : {
80 const listType = b.type === "bulleted" ? "BULLETED_LIST" : "ORDERED_LIST" ;
81 nodes. push ({
82 type: listType, id: id (),
83 nodes: (b.items ?? []). map (( item ) => ({ type: "LIST_ITEM" , id: id (), nodes: [ mkParagraph ( id (), item)] })),
84 });
85 break ;
86 }
87 case "paragraph" :
88 default :
89 nodes. push ( mkParagraph ( id (), b.text));
90 }
91 }
92 return { nodes };
93 }
94
95 // One post's plain data -> a flat Blog V3 draft-post object. media is omitted — covers are a
96 // separate pass (attachPostCovers), per the recipe.
97 function buildPost ( p , i , memberId ) {
98 return {
99 title: p.title,
100 memberId,
101 richContent: p.richContent ?? mkRichContent (p.content, i),
102 ... (p.categoryIds ? { categoryIds: p.categoryIds } : {}),
103 ... (p.tagIds ? { tagIds: p.tagIds } : {}),
104 };
105 }
106
107 // ---- operations ----------------------------------------------------------------------------
108
109 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
110 export async function installBlogApp ( ctx ) {
111 try {
112 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
113 tenant: { tenantType: "SITE" , id: ctx.siteId },
114 appInstance: { appDefId: BLOG_APP_ID , enabled: true },
115 } });
116 } catch {
117 /* already installed is fine */
118 }
119 }
120
121 // Every post create needs a REAL author memberId — a fabricated id fails with
122 // "memberIds ... do not exist". A provisioned site has the owner as members[0].
123 // docs: https://dev.wix.com/docs/api-reference/crm/members-contacts/members/members/list-members.md
124 export async function getAuthorMemberId ( ctx ) {
125 const r = await req (ctx, "/members/v1/members?fieldsets=PUBLIC&paging.limit=1" , { method: "GET" });
126 const id = r.members?.[ 0 ]?.id;
127 if ( ! id) throw new Error ( `No site member found for author attribution: ${ JSON . stringify ( r ). slice ( 0 , 400 ) }` );
128 return id;
129 }
130
131 /**
132 * Create posts, PUBLISHED (publish:true — unpublished posts never reach visitors).
133 * Endpoint auto-selected per the recipe: single-post endpoint for exactly one (nested
134 * `{ draftPost }` envelope), bulk for >= 2 — `bulk` sits BETWEEN v3 and draft-posts, and each
135 * bulk item is FLAT (wrapping it in `draftPost` 400s). Returns [{ id, index, success }].
136 * docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/create-draft-post.md
137 * docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/bulk-create-draft-posts.md
138 */
139 export async function createPosts ( ctx , posts , { memberId , publish = true } = {}) {
140 if ( ! memberId) throw new Error ( "createPosts requires opts.memberId (see getAuthorMemberId)" );
141 if (posts. length === 1 ) {
142 const r = await req (ctx, "/blog/v3/draft-posts" , { body: { draftPost: buildPost (posts[ 0 ], 0 , memberId), publish } });
143 return [{ id: r.draftPost?.id, index: 0 , success: !! r.draftPost?.id }];
144 }
145 const r = await req (ctx, "/blog/v3/bulk/draft-posts/create" , {
146 body: { draftPosts: posts. map (( p , i ) => buildPost (p, i, memberId)), publish },
147 });
148 // Bulk returns 200 even on partial failure — read per-item results[].itemMetadata.success.
149 return (r.results ?? []). map (( x ) => ({
150 id: x.itemMetadata?.id, index: x.itemMetadata?.originalIndex, success: !! x.itemMetadata?.success,
151 }));
152 }
153
154 // Existing label -> id, straight from the query (the source of truth for what persisted).
155 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/category/query-categories.md
156 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/tags/query-tags.md
157 async function labelIdMap ( ctx , kind ) {
158 const path = kind === "categories" ? "/blog/v3/categories/query" : "/v3/tags/query" ;
159 const r = await req (ctx, path, { body: { query: { paging: { limit: 100 } } } });
160 return new Map ((r[kind] ?? []). map (( x ) => [x.label, x.id]));
161 }
162
163 // Create category/tag labels resiliently. TWO hazards this absorbs:
164 // 1. Fresh-install provisioning window — for a few seconds after the Blog app installs,
165 // per-item category/tag creates return 200 with an id but DON'T persist (the id is a lie).
166 // So the create response is never trusted — re-query, treat the query as truth, re-create
167 // what's still missing until it sticks.
168 // 2. Idempotency — an already-present label (a partial-failure re-run) is skipped.
169 // Category bodies are NESTED (`{ category: { label } }`); tag bodies are FLAT (`{ label }`) —
170 // a `{ tag: { label } }` body sends an empty top-level label and 400s. Returns [{ id, name }].
171 async function ensureLabels ( ctx , kind , createPath , mkBody , names ) {
172 let map = await labelIdMap (ctx, kind);
173 for ( let attempt = 0 ; attempt < 8 ; attempt ++ ) {
174 const missing = names. filter (( n ) => ! map. has (n));
175 if ( ! missing. length ) break ;
176 if (attempt) await sleep ( 1500 ); // backoff only between retries — happy path pays nothing
177 for ( const name of missing) {
178 try { await req (ctx, createPath, { body: mkBody (name) }); }
179 catch (e) { if ( ! String (e.message). includes ( "-> 409" )) throw e; } // 409 = raced, already there
180 }
181 map = await labelIdMap (ctx, kind);
182 }
183 return names. map (( name ) => ({ id: map. get (name), name }));
184 }
185
186 /** Create categories idempotently by label (no bulk endpoint). Feed ids into post.categoryIds. */
187 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/category/create-category.md
188 export async function createCategories ( ctx , names ) {
189 return ensureLabels (ctx, "categories" , "/blog/v3/categories" , ( name ) => ({ category: { label: name } }), names);
190 }
191
192 /** Create tags idempotently by label. Feed ids into post.tagIds. */
193 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/tags/create-tag.md
194 export async function createTags ( ctx , names ) {
195 return ensureLabels (ctx, "tags" , "/blog/v3/tags" , ( name ) => ({ label: name }), names);
196 }
197
198 // Blog binds a post cover by Wix Media file ID — an external url must be imported first; a
199 // plan `coverImagePrompt` is generated (Wix AI, 1 credit) then imported. Both live in the
200 // shared util (parallel, resilient, never blocks the seed).
201 export { importImage } from "../../shared/seed/images.mjs" ;
202
203 // covers: [{ postId, fileId }] where fileId is the Wix Media file.id from importImage. Per
204 // post: PATCH /blog/v3/draft-posts/{id} (NOT POST …/{id}/update — that 404s), setting
205 // media.displayed:true + media.custom:true + wixMedia.image.id (the id ALONE is a silent
206 // no-op), then RE-PUBLISH — the PATCH sets hasUnpublishedChanges, so the live post stays
207 // cover-less until republished. Image failures never block the run.
208 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/update-draft-post.md
209 // docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/publish-draft-post.md
210 export async function attachPostCovers ( ctx , covers ) {
211 let attached = 0 ;
212 for ( const { postId , fileId } of covers) {
213 try {
214 await req (ctx, `/blog/v3/draft-posts/${ postId }` , {
215 method: "PATCH" ,
216 body: { draftPost: { media: { displayed: true , custom: true , wixMedia: { image: { id: fileId } } } } },
217 });
218 await req (ctx, `/blog/v3/draft-posts/${ postId }/publish` , { method: "POST" });
219 attached ++ ;
220 } catch (e) {
221 console. error ( `cover attach skipped for post ${ postId }: ${ e . message }` );
222 }
223 }
224 return attached;
225 }
226
227 /**
228 * ONE-CALL seed: install → author memberId → categories/tags (names resolved to ids) →
229 * published posts → covers, ids threaded in memory. The default path.
230 */
231 export async function setupBlog ( ctx , { posts = [], categories = [], tags = [] } = {}) {
232 await installBlogApp (ctx);
233 await sleep ( 3000 ); // let a fresh Blog install settle so the first category/tag writes stick
234 // (correctness is still guaranteed by ensureLabels' verify-retry).
235 const memberId = await getAuthorMemberId (ctx);
236
237 const catNames = [ ...new Set ([ ... categories, ... posts. flatMap (( p ) => []. concat (p.category ?? [], p.categories ?? []))])];
238 const tagNames = [ ...new Set ([ ... tags, ... posts. flatMap (( p ) => []. concat (p.tags ?? []))])];
239 const cats = catNames. length ? await createCategories (ctx, catNames) : [];
240 const tgs = tagNames. length ? await createTags (ctx, tagNames) : [];
241 const catId = new Map (cats. map (( c ) => [c.name, c.id]));
242 const tagId = new Map (tgs. map (( t ) => [t.name, t.id]));
243
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
254 // Pass 2 — covers: resolve (import by url / generate by prompt) in one parallel wave, then
255 // attach (PATCH + re-publish per post). Failures leave the post text-only; the seed's exit
256 // never depends on images.
257 const files = await resolveItemImages (ctx, created. map (( c , i ) => (
258 c?.id && c?.success
259 ? { path: posts[i]?.coverImagePath, url: posts[i]?.coverImageUrl, prompt: posts[i]?.coverImagePrompt, displayName: `post-${ i }.png` }
260 : null
261 )));
262 const covers = created
263 . map (( c , i ) => (files[i] ? { postId: c.id, fileId: files[i].id } : null ))
264 . filter (Boolean);
265 const coversAttached = covers. length ? await attachPostCovers (ctx, covers) : 0 ;
266
267 return { posts: created, categories: cats, tags: tgs, coversAttached };
268 }
269
270 // ---- CLI entry ----------------------------------------------------------------------------------
271
272 const invokedDirectly = process.argv[ 1 ] && import . meta .url. endsWith (process.argv[ 1 ]. split ( "/" ). pop ());
273 if (invokedDirectly) {
274 const planPath = process.argv[ 2 ];
275 if ( ! planPath) {
276 console. error ( "usage: node seed-blog.mjs <plan.json> (run from the project root)" );
277 process. exit ( 1 );
278 }
279 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
280 const ctx = makeCtx ();
281 setupBlog (ctx, plan)
282 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
283 . catch (( e ) => {
284 console. error (e.message);
285 process. exit ( 1 );
286 });
287 }