← Files WixARCHIVED FILE

skills/wix-headless-templates/blog/seed/seed-blog.mjs

16.8 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// Blog seed — a BUILD-TIME script, never shipped in the app. Run from the project root
// (where wix.config.json lives) with a plan file:
//
//   node <SKILL_ROOT>/templates/blog/seed/seed-blog.mjs plan.json
//
// It mints its own site token via the Wix CLI, installs the Wix Blog app if needed, resolves
// a real author memberId (every post create requires one), creates categories/tags (idempotent
// by label, with a fresh-install verify-retry), bulk-creates PUBLISHED posts with Ricos
// richContent, and imports+attaches cover images (PATCH + re-publish). Prints a JSON result
// to stdout.
//
// Plan shape (see SEED.md):
//   { "categories"?: [name], "tags"?: [name],
//     "posts": [{ "title", "content": [blocks] | "richContent"?, "category"?|"categories"?,
//                 "tags"?, "coverImageUrl"? | "coverImagePrompt"? }] }
//   content blocks: { type:"heading", text, level? } | { type:"paragraph", text }
//     | { type:"quote", text } | { type:"bulleted"|"ordered", items:[text,…] }
//
// Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
// read the live API reference; every call below
// carries a docs: line with its reference page.
import { readFileSync } from "node:fs";
import { resolveItemImages } from "../../shared/seed/images.mjs";
import { seedSiteId } from "../../shared/seed/site-context.mjs";
import { wixToken } from "../../shared/seed/wix-cli.mjs";

const API = "https://www.wixapis.com";
const BLOG_APP_ID = "14bcded7-0066-7c35-14d7-466cb3f09103";

export function makeCtx({ cwd = process.cwd() } = {}) {
  // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
  // a seed there unless --allow-parent is passed after the user confirmed).
  const siteId = seedSiteId({ cwd, argv: process.argv });
  const token = wixToken(siteId, cwd);
  return { token, siteId };
}

async function req(ctx, path, { method = "POST", body } = {}) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${ctx.token}`,
      "wix-site-id": ctx.siteId,
      "Content-Type": "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 400)}`);
  return json;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// ---- Ricos richContent builder (setup-blog.md § "CRITICAL RICOS NESTING") --------------------
// Rules baked in: TEXT is always a leaf inside a container; BLOCKQUOTE / LIST_ITEM wrap a
// PARAGRAPH; BULLETED_LIST / ORDERED_LIST wrap LIST_ITEM -> PARAGRAPH -> TEXT; every container
// node gets a unique id, TEXT leaves use id "". For node types not covered here (code, images)
// pass a pre-built `richContent` on the post instead — it's used verbatim.
const mkText = (text) => ({ type: "TEXT", id: "", nodes: [], textData: { text: text || "", decorations: [] } });
const mkParagraph = (id, text) => ({ type: "PARAGRAPH", id, nodes: [mkText(text)], paragraphData: {} });

function mkRichContent(blocks = [], postIdx = 0) {
  let n = 0;
  const id = () => `p${postIdx}-n${n++}`;
  const nodes = [];
  for (const b of blocks) {
    switch (b.type) {
      case "heading":
        nodes.push({ type: "HEADING", id: id(), nodes: [mkText(b.text)], headingData: { level: b.level ?? 2 } });
        break;
      case "quote":
        nodes.push({ type: "BLOCKQUOTE", id: id(), nodes: [mkParagraph(id(), b.text)], blockquoteData: { indentation: 1 } });
        break;
      case "bulleted":
      case "ordered": {
        const listType = b.type === "bulleted" ? "BULLETED_LIST" : "ORDERED_LIST";
        nodes.push({
          type: listType, id: id(),
          nodes: (b.items ?? []).map((item) => ({ type: "LIST_ITEM", id: id(), nodes: [mkParagraph(id(), item)] })),
        });
        break;
      }
      case "paragraph":
      default:
        nodes.push(mkParagraph(id(), b.text));
    }
  }
  return { nodes };
}

// One post's plain data -> a flat Blog V3 draft-post object. media is omitted — covers are a
// separate pass (attachPostCovers), per the recipe.
function buildPost(p, i, memberId) {
  return {
    title: p.title,
    memberId,
    richContent: p.richContent ?? mkRichContent(p.content, i),
    ...(p.categoryIds ? { categoryIds: p.categoryIds } : {}),
    ...(p.tagIds ? { tagIds: p.tagIds } : {}),
  };
}

// ---- operations ----------------------------------------------------------------------------

// docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
export async function installBlogApp(ctx) {
  try {
    await req(ctx, "/apps-installer-service/v1/app-instance/install", { body: {
      tenant: { tenantType: "SITE", id: ctx.siteId },
      appInstance: { appDefId: BLOG_APP_ID, enabled: true },
    } });
  } catch {
    /* already installed is fine */
  }
}

// Every post create needs a REAL author memberId — a fabricated id fails with
// "memberIds ... do not exist". A provisioned site has the owner as members[0].
// docs: https://dev.wix.com/docs/api-reference/crm/members-contacts/members/members/list-members.md
export async function getAuthorMemberId(ctx) {
  const r = await req(ctx, "/members/v1/members?fieldsets=PUBLIC&paging.limit=1", { method: "GET" });
  const id = r.members?.[0]?.id;
  if (!id) throw new Error(`No site member found for author attribution: ${JSON.stringify(r).slice(0, 400)}`);
  return id;
}

/**
 * Create posts, PUBLISHED (publish:true — unpublished posts never reach visitors).
 * Endpoint auto-selected per the recipe: single-post endpoint for exactly one (nested
 * `{ draftPost }` envelope), bulk for >= 2 — `bulk` sits BETWEEN v3 and draft-posts, and each
 * bulk item is FLAT (wrapping it in `draftPost` 400s). Returns [{ id, index, success }].
 * docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/create-draft-post.md
 * docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/bulk-create-draft-posts.md
 */
export async function createPosts(ctx, posts, { memberId, publish = true } = {}) {
  if (!memberId) throw new Error("createPosts requires opts.memberId (see getAuthorMemberId)");
  if (posts.length === 1) {
    const r = await req(ctx, "/blog/v3/draft-posts", { body: { draftPost: buildPost(posts[0], 0, memberId), publish } });
    return [{ id: r.draftPost?.id, index: 0, success: !!r.draftPost?.id }];
  }
  const r = await req(ctx, "/blog/v3/bulk/draft-posts/create", {
    body: { draftPosts: posts.map((p, i) => buildPost(p, i, memberId)), publish },
  });
  // Bulk returns 200 even on partial failure — read per-item results[].itemMetadata.success.
  return (r.results ?? []).map((x) => ({
    id: x.itemMetadata?.id, index: x.itemMetadata?.originalIndex, success: !!x.itemMetadata?.success,
  }));
}

// Existing label -> id, straight from the query (the source of truth for what persisted).
// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/category/query-categories.md
// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/tags/query-tags.md
// Both live under /blog/v3/ on the public gateway (the SDK's host mapping also accepts the bare
// /v3/tags alias the earlier seeds called — one path for one endpoint here, the same one rest/ uses).
async function labelIdMap(ctx, kind) {
  const path = kind === "categories" ? "/blog/v3/categories/query" : "/blog/v3/tags/query";
  const r = await req(ctx, path, { body: { query: { paging: { limit: 100 } } } });
  return new Map((r[kind] ?? []).map((x) => [x.label, x.id]));
}

// Create category/tag labels resiliently. TWO hazards this absorbs:
//  1. Fresh-install provisioning window — for a few seconds after the Blog app installs,
//     per-item category/tag creates return 200 with an id but DON'T persist (the id is a lie).
//     So the create response is never trusted — re-query, treat the query as truth, re-create
//     what's still missing until it sticks.
//  2. Idempotency — an already-present label (a partial-failure re-run) is skipped.
// Category bodies are NESTED (`{ category: { label } }`); tag bodies are FLAT (`{ label }`) —
// a `{ tag: { label } }` body sends an empty top-level label and 400s. Returns [{ id, name }].
async function ensureLabels(ctx, kind, createPath, mkBody, names) {
  let map = await labelIdMap(ctx, kind);
  for (let attempt = 0; attempt < 8; attempt++) {
    const missing = names.filter((n) => !map.has(n));
    if (!missing.length) break;
    if (attempt) await sleep(1500); // backoff only between retries — happy path pays nothing
    for (const name of missing) {
      try { await req(ctx, createPath, { body: mkBody(name) }); }
      catch (e) { if (!String(e.message).includes("-> 409")) throw e; } // 409 = raced, already there
    }
    map = await labelIdMap(ctx, kind);
  }
  return names.map((name) => ({ id: map.get(name), name }));
}

/** Create categories idempotently by label (no bulk endpoint). Feed ids into post.categoryIds. */
// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/category/create-category.md
export async function createCategories(ctx, names) {
  return ensureLabels(ctx, "categories", "/blog/v3/categories", (name) => ({ category: { label: name } }), names);
}

/** Create tags idempotently by label. Feed ids into post.tagIds. */
// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/tags/create-tag.md
export async function createTags(ctx, names) {
  return ensureLabels(ctx, "tags", "/blog/v3/tags", (name) => ({ label: name }), names);
}

// Blog binds a post cover by Wix Media file ID — an external url must be imported first; a
// plan `coverImagePrompt` is generated (Wix AI, 1 credit) then imported. Both live in the
// shared util (parallel, resilient, never blocks the seed).
export { importImage } from "../../shared/seed/images.mjs";

// covers: [{ postId, fileId }] where fileId is the Wix Media file.id from importImage. Per
// post: PATCH /blog/v3/draft-posts/{id} (NOT POST …/{id}/update — that 404s), setting
// media.displayed:true + media.custom:true + wixMedia.image.id (the id ALONE is a silent
// no-op), then RE-PUBLISH — the PATCH sets hasUnpublishedChanges, so the live post stays
// cover-less until republished. Image failures never block the run.
// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/update-draft-post.md
// docs: https://dev.wix.com/docs/api-reference/business-solutions/blog/draft-posts/publish-draft-post.md
export async function attachPostCovers(ctx, covers) {
  let attached = 0;
  for (const { postId, fileId } of covers) {
    try {
      await req(ctx, `/blog/v3/draft-posts/${postId}`, {
        method: "PATCH",
        body: { draftPost: { media: { displayed: true, custom: true, wixMedia: { image: { id: fileId } } } } },
      });
      await req(ctx, `/blog/v3/draft-posts/${postId}/publish`, { method: "POST" });
      attached++;
    } catch (e) {
      console.error(`cover attach skipped for post ${postId}: ${e.message}`);
    }
  }
  return attached;
}

/**
 * ONE-CALL seed: install → author memberId → categories/tags (names resolved to ids) →
 * published posts → covers, ids threaded in memory. The default path.
 */
export async function setupBlog(ctx, { posts = [], categories = [], tags = [] } = {}) {
  await installBlogApp(ctx);
  await sleep(3000); // let a fresh Blog install settle so the first category/tag writes stick
                     // (correctness is still guaranteed by ensureLabels' verify-retry).
  const memberId = await getAuthorMemberId(ctx);

  const catNames = [...new Set([...categories, ...posts.flatMap((p) => [].concat(p.category ?? [], p.categories ?? []))])];
  const tagNames = [...new Set([...tags, ...posts.flatMap((p) => [].concat(p.tags ?? []))])];
  const cats = catNames.length ? await createCategories(ctx, catNames) : [];
  const tgs = tagNames.length ? await createTags(ctx, tagNames) : [];
  const catId = new Map(cats.map((c) => [c.name, c.id]));
  const tagId = new Map(tgs.map((t) => [t.name, t.id]));

  const created = await createPosts(ctx, posts.map((p) => {
    const cn = [].concat(p.category ?? [], p.categories ?? []);
    const tn = [].concat(p.tags ?? []);
    return {
      ...p,
      ...(cn.length ? { categoryIds: cn.map((n) => catId.get(n)).filter(Boolean) } : {}),
      ...(tn.length ? { tagIds: tn.map((n) => tagId.get(n)).filter(Boolean) } : {}),
    };
  }), { memberId });

  // Pass 2 — covers: resolve (import by url / generate by prompt) in one parallel wave, then
  // attach (PATCH + re-publish per post). Failures leave the post text-only; the seed's exit
  // never depends on images.
  const files = await resolveItemImages(ctx, created.map((c, i) => (
    c?.id && c?.success
      ? { path: posts[i]?.coverImagePath, url: posts[i]?.coverImageUrl, prompt: posts[i]?.coverImagePrompt, displayName: `post-${i}.png` }
      : null
  )));
  const covers = created
    .map((c, i) => (files[i] ? { postId: c.id, fileId: files[i].id } : null))
    .filter(Boolean);
  const coversAttached = covers.length ? await attachPostCovers(ctx, covers) : 0;

  // Pass 3 — a fresh Blog install can wipe categories (and tags) created in its first minute AFTER
  // the query has confirmed them (run 151: three categories confirmed by the seed, gone by release,
  // the posts left pointing at dead ids). So they are read back BY ID here, at the end; a missing one
  // is created again and every post that named it is re-linked (PATCH the draft, publish).
  const relinked = await ensureLabelsPersisted(ctx, { cats, tgs, created, posts, catId, tagId });

  return { posts: created, categories: relinked.categories, tags: relinked.tags, coversAttached, relinkedPosts: relinked.posts };
}

// GET /blog/v3/categories/{id} · GET /blog/v3/tags/{id} — 404 when the fresh-install wipe took it.
async function labelExists(ctx, kind, id) {
  try {
    await req(ctx, `/blog/v3/${kind}/${id}`, { method: "GET" });
    return true;
  } catch (e) {
    if (String(e.message).includes("-> 404")) return false;
    throw e;
  }
}

async function ensureLabelsPersisted(ctx, { cats, tgs, created, posts, catId, tagId }) {
  const remapCat = new Map();
  const remapTag = new Map();
  const missingCats = [];
  const missingTags = [];
  for (const c of cats) if (c.id && !(await labelExists(ctx, "categories", c.id))) missingCats.push(c);
  for (const t of tgs) if (t.id && !(await labelExists(ctx, "tags", t.id))) missingTags.push(t);
  if (!missingCats.length && !missingTags.length) return { categories: cats, tags: tgs, posts: 0 };
  const freshCats = missingCats.length ? await createCategories(ctx, missingCats.map((c) => c.name)) : [];
  const freshTags = missingTags.length ? await createTags(ctx, missingTags.map((t) => t.name)) : [];
  for (const [i, c] of missingCats.entries()) if (freshCats[i]?.id) { remapCat.set(c.id, freshCats[i].id); catId.set(c.name, freshCats[i].id); }
  for (const [i, t] of missingTags.entries()) if (freshTags[i]?.id) { remapTag.set(t.id, freshTags[i].id); tagId.set(t.name, freshTags[i].id); }
  let relinked = 0;
  for (const [i, c] of created.entries()) {
    if (!c?.id || !c?.success) continue;
    const cn = [].concat(posts[i]?.category ?? [], posts[i]?.categories ?? []);
    const tn = [].concat(posts[i]?.tags ?? []);
    const wantsCats = cn.map((n) => catId.get(n)).filter(Boolean);
    const wantsTags = tn.map((n) => tagId.get(n)).filter(Boolean);
    const touched = cn.some((n) => [...remapCat.values()].includes(catId.get(n))) || tn.some((n) => [...remapTag.values()].includes(tagId.get(n)));
    if (!touched) continue;
    try {
      await req(ctx, `/blog/v3/draft-posts/${c.id}`, { method: "PATCH", body: { draftPost: { ...(cn.length ? { categoryIds: wantsCats } : {}), ...(tn.length ? { tagIds: wantsTags } : {}) } } });
      await req(ctx, `/blog/v3/draft-posts/${c.id}/publish`, { method: "POST" });
      relinked++;
    } catch (e) {
      console.error(`relink skipped for post ${c.id}: ${e.message}`);
    }
  }
  const categories = cats.map((c) => ({ ...c, id: remapCat.get(c.id) ?? c.id, recreated: remapCat.has(c.id) || undefined }));
  const tags = tgs.map((t) => ({ ...t, id: remapTag.get(t.id) ?? t.id, recreated: remapTag.has(t.id) || undefined }));
  return { categories, tags, posts: relinked };
}

// ---- CLI entry ----------------------------------------------------------------------------------

const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop());
if (invokedDirectly) {
  const planPath = process.argv[2];
  if (!planPath) {
    console.error("usage: node seed-blog.mjs <plan.json>   (run from the project root)");
    process.exit(1);
  }
  const plan = JSON.parse(readFileSync(planPath, "utf8"));
  const ctx = makeCtx();
  setupBlog(ctx, plan)
    .then((result) => console.log(JSON.stringify(result, null, 2)))
    .catch((e) => {
      console.error(e.message);
      process.exit(1);
    });
}

SHA-256: 330306d118b508bbc2ef1a5f7070c0bd028af19b5d344d3c74e03645fa8c047d