@bettercms-ai/mcp 0.38.2 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2133,7 +2133,9 @@ var fieldShape = {
2133
2133
  richText: z.boolean().optional().describe(
2134
2134
  "prose formatting. DEFAULTS TO TRUE for 'text': the field is created as rich text so editors can bold, link and format it on the canvas, and the API returns rich text (render with rich() from @bettercms-ai/sdk; plain() for titles and meta). Pass false for a value that is NOT prose and must stay a bare string \u2014 a URL/href, a slug, an id, an email, a phone number, a CSS class, an icon name. A link stored as rich text will not work as an href."
2135
2135
  ),
2136
- options: z.array(z.string()).optional().describe("choices when type is 'select'"),
2136
+ options: z.array(z.union([z.string(), z.object({ label: z.string(), value: z.string() })])).optional().describe(
2137
+ "choices when type is 'select'. A plain string, or {label, value} when the stored value differs from what the editor shows (e.g. {label:'Sold out', value:'soldOut'})."
2138
+ ),
2137
2139
  config: z.record(z.string(), z.unknown()).optional().describe(
2138
2140
  "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model"
2139
2141
  ),
@@ -2162,9 +2164,34 @@ var fieldShape = {
2162
2164
  ),
2163
2165
  ui: uiObject.optional().describe(
2164
2166
  "AUTHORING CHROME \u2014 how this field presents in the editor. Never a delivery effect. `preview` and `layout` are valid ONLY on a nesting field ('group', 'repeater', an 'array' with config.zones, or 'modular'); `reorderable` only where there is a LIST to lock ('repeater', an 'array' with config.zones.repeatable, or 'modular' \u2014 a 'group' holds one item). preview keys must name real children, or the write is refused with the valid keys listed. An unknown key inside `ui` is a 400, not a silent strip. Read it back with get_content_model / get_page to confirm it landed. There is deliberately no `order`: field order is the array index \u2014 use add_field's `after` to insert."
2167
+ ),
2168
+ // ── G5: the four keys the backend field schema accepts that this whitelist stripped. ──
2169
+ fieldsetId: z.string().min(1).optional().describe(
2170
+ "id of one of this model's `fieldsets` (create_content_model / update_content_model). The field is shown inside that card in the editor. Editor-only: never changes what is stored or delivered."
2171
+ ),
2172
+ placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).optional().describe(
2173
+ "where the control sits in the entry editor: 'title' the entry's heading, 'cover' its lead image, 'excerpt' the summary, 'body' the document body, 'inline' or 'panel'. Omit to let the editor decide. Editor-only."
2174
+ ),
2175
+ defaultValue: z.unknown().optional().describe("the value a new entry starts with"),
2176
+ searchable: z.boolean().optional().describe("omit (\u2261 true) to index this field for site search; false excludes it"),
2177
+ unique: z.boolean().optional().describe(
2178
+ "true: no two entries of this model may hold the same value (compared trimmed and case-insensitively). On a repeater child such as a variant SKU it also covers every row of every entry. Only on text, longtext, slug, email, phone, link and number. A clash is refused with 409 `unique_conflict`."
2165
2179
  )
2166
2180
  };
2167
2181
  var fieldObject = z.object(fieldShape);
2182
+ var urlPatternArg = z.string().nullable().optional().describe(
2183
+ "the public URL of one entry, e.g. '/blog/:slug'. Must start with '/' and contain ':slug' exactly once ('{slug}' is refused). Drives RSS item links and the automatic 301 when an entry's slug changes. null clears it."
2184
+ );
2185
+ var fieldsetsArg = z.array(
2186
+ z.object({
2187
+ id: z.string().min(1),
2188
+ name: z.string().min(1).max(60),
2189
+ description: z.string().max(500).optional()
2190
+ })
2191
+ ).max(30).optional().describe(
2192
+ "the model's editor cards: [{id, name, description?}], at most 30, ids unique, order = array index. `description` is one line under the card title. A field joins one with `fieldsetId`. Editor-only: never changes what is stored or delivered."
2193
+ );
2194
+ var RICH_TEXT_NOTE = "\u{1F534} A field of type 'text' is created as RICH TEXT unless you send `richText: false` on it. Send `richText: false` for every value that is not prose: a name used as a title, a URL, slug, id, SKU, email, phone, CSS class or icon name.";
2168
2195
  function flatFieldKeys(fields) {
2169
2196
  const out = [];
2170
2197
  const childrenOf = (f) => {
@@ -2205,7 +2232,14 @@ function toField(f) {
2205
2232
  ...f.required !== void 0 ? { required: f.required } : {},
2206
2233
  ...f.helpText !== void 0 ? { helpText: f.helpText } : {},
2207
2234
  ...f.showIf !== void 0 ? { showIf: f.showIf } : {},
2208
- ...f.ui !== void 0 ? { ui: f.ui } : {}
2235
+ ...f.ui !== void 0 ? { ui: f.ui } : {},
2236
+ // G5: these four persist on the backend and were stripped here, like helpText once was.
2237
+ ...f.fieldsetId !== void 0 ? { fieldsetId: f.fieldsetId } : {},
2238
+ ...f.placement !== void 0 ? { placement: f.placement } : {},
2239
+ ...f.defaultValue !== void 0 ? { defaultValue: f.defaultValue } : {},
2240
+ ...f.searchable !== void 0 ? { searchable: f.searchable } : {},
2241
+ // BE-6: a leaf key like the four above, so it rides on `base` and survives every branch.
2242
+ ...f.unique !== void 0 ? { unique: f.unique } : {}
2209
2243
  };
2210
2244
  if (f.type === "group") {
2211
2245
  return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
@@ -2415,12 +2449,14 @@ function buildToolDefs(deps) {
2415
2449
  name: z.string().min(1).describe("human model name, e.g. 'Blog Post'"),
2416
2450
  slug: slug.describe("url-safe unique slug, e.g. 'blog-post'"),
2417
2451
  description: z.string().optional(),
2452
+ urlPattern: urlPatternArg,
2418
2453
  kind: z.enum(["model", "block"]).optional().describe(
2419
2454
  "'model' (default) = a collection with its own entries. 'block' = a type that exists only to be stacked inside another model's 'modular' field \u2014 it holds no entries, and create_content_entry against it is refused. Create blocks FIRST, then the model whose modular field lists their slugs. Cannot be changed later."
2420
2455
  ),
2421
2456
  fields: z.array(fieldObject).optional().describe(
2422
2457
  "the model's typed schema fields. 'group'/'repeater' NEST their child fields (any depth) \u2014 don't flatten zones into top-level fields."
2423
- )
2458
+ ),
2459
+ fieldsets: fieldsetsArg
2424
2460
  });
2425
2461
  const afterArg = z.string().min(1).optional().describe(
2426
2462
  "OPTIONAL insertion point: the key of an existing TOP-LEVEL field to insert this one directly after. Omit to append at the end (the default, and the behaviour before this existed). Use it to put a field where an author expects it instead of at the bottom \u2014 e.g. after:'hero_title' for a subtitle. An unknown key is refused rather than silently appended."
@@ -2441,6 +2477,7 @@ function buildToolDefs(deps) {
2441
2477
  filename: z.string().optional().describe("override the stored filename"),
2442
2478
  altText: z.string().optional().describe("accessibility alt text"),
2443
2479
  caption: z.string().optional(),
2480
+ transcript: z.string().max(1e5).optional().describe("video/audio only: the spoken words (\u2264 100,000 chars), published wherever the file is used"),
2444
2481
  folderId: z.string().optional().describe("target Media Library folder (defaults to project root)")
2445
2482
  });
2446
2483
  const createEntryInput = z.object({
@@ -2668,6 +2705,18 @@ function buildToolDefs(deps) {
2668
2705
  "REPLACES the prop array \u2014 include every prop you want to keep, WITH its stored `ui`, or that prop's authoring chrome is dropped along with the prop. Read get_component first."
2669
2706
  )
2670
2707
  });
2708
+ const setComponentSourceInput = z.object({
2709
+ componentId: z.string().min(1).describe("component id (from list_components)"),
2710
+ path: z.string().min(1).max(512).describe("file path relative to the app root, e.g. src/components/sections/Hero.astro"),
2711
+ export: z.string().min(1).max(128).optional().describe("named export that renders it; omit for a default export"),
2712
+ kind: z.enum(["file", "section"]).optional().describe("'file' (props are this component's fields; default) or 'section' (a codemod-extracted section: props { blockId, bind, overrides, page })")
2713
+ });
2714
+ const submitComponentizeReceiptInput = z.object({
2715
+ receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --componentize --receipt <file>` wrote, verbatim.")
2716
+ });
2717
+ const clearComponentSourceInput = z.object({
2718
+ componentId: z.string().min(1).describe("component id (from list_components)")
2719
+ });
2671
2720
  const getComponentInput = z.object({
2672
2721
  componentId: z.string().min(1).describe("component id (from list_components)")
2673
2722
  });
@@ -2819,9 +2868,22 @@ function buildToolDefs(deps) {
2819
2868
  filename: z.string().min(1).describe("file name incl. extension"),
2820
2869
  altText: z.string().optional(),
2821
2870
  caption: z.string().optional(),
2871
+ transcript: z.string().max(1e5).optional().describe("video/audio only: the spoken words (\u2264 100,000 chars)"),
2822
2872
  folderId: z.string().optional()
2823
2873
  }).shape,
2824
- async (c, a) => ok("Media asset.", await data(c, "POST", `/management/media/from-upload`, { assetId: a.assetId, uploadKey: a.uploadKey, filename: a.filename, altText: a.altText, caption: a.caption, folderId: a.folderId }))
2874
+ async (c, a) => ok("Media asset.", await data(c, "POST", `/management/media/from-upload`, { assetId: a.assetId, uploadKey: a.uploadKey, filename: a.filename, altText: a.altText, caption: a.caption, transcript: a.transcript, folderId: a.folderId }))
2875
+ ),
2876
+ def(
2877
+ "update_media",
2878
+ "Update a media asset",
2879
+ "Describe an existing media asset: set its alt text, caption or transcript. A transcript (video/audio only, \u2264 100,000 chars) is stored ON the asset, so every page and entry using the file publishes it \u2014 hosted pages, the .md twin, llms-full.txt and the delivery API. Pass null to clear a field. Use this after your own speech-to-text; BetterCMS does not generate transcripts.",
2880
+ z.object({
2881
+ assetId: z.string().min(1).describe("media asset id (from list_media / upload_asset)"),
2882
+ altText: z.string().max(500).nullable().optional().describe("alt text; null clears"),
2883
+ caption: z.string().max(1e3).nullable().optional().describe("caption; null clears"),
2884
+ transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only.")
2885
+ }).shape,
2886
+ async (c, a) => ok("Updated media asset.", await data(c, "PATCH", `/management/media/${encodeURIComponent(s(a.assetId))}`, { altText: a.altText, caption: a.caption, transcript: a.transcript }))
2825
2887
  ),
2826
2888
  def(
2827
2889
  "list_media",
@@ -3091,9 +3153,10 @@ function buildToolDefs(deps) {
3091
3153
  def(
3092
3154
  "update_content_model",
3093
3155
  "Update a content model's metadata",
3094
- "Rename a content model or edit its description/slug (metadata only \u2014 does NOT touch fields; use add_field to extend the schema). Provide modelId plus the fields to change.",
3095
- z.object({ modelId: z.string().min(1), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional() }).shape,
3096
- async (c, a) => ok("Updated content model.", await data(c, "PATCH", `/management/content/models/${s(a.modelId)}`, { name: a.name, slug: a.slug, description: a.description }))
3156
+ "Rename a content model, edit its description/slug/urlPattern, or set its `fieldsets`. Never touches `fields`: use add_field to extend the schema. Provide modelId plus what to change. `fieldsets` REPLACES the list (send null to drop them all); then put a field in one with add_field's `fieldsetId`. `urlPattern` is the collection's entry URL, e.g. '/blog/:slug' (null clears it). A slug already used in this project and branch is refused with 409 `slug_taken`.",
3157
+ z.object({ modelId: z.string().min(1), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), urlPattern: urlPatternArg, fieldsets: fieldsetsArg.nullable() }).shape,
3158
+ // `fields` is deliberately not sent: the PATCH replaces `fields` wholesale when present.
3159
+ async (c, a) => ok("Updated content model.", await data(c, "PATCH", `/management/content/models/${s(a.modelId)}`, { name: a.name, slug: a.slug, description: a.description, urlPattern: a.urlPattern, fieldsets: a.fieldsets }))
3097
3160
  ),
3098
3161
  def(
3099
3162
  "get_content_types",
@@ -3108,7 +3171,20 @@ function buildToolDefs(deps) {
3108
3171
  "Edit a page",
3109
3172
  "Edit a page: title, slug, SEO metaTitle/metaDescription, publish status (draft|published), and `blockJson` (its block composition \u2014 passing it REPLACES the whole array, so read get_page first). It does NOT change the field SCHEMA \u2014 use add_page_field / set_page_content for that. Renaming the slug keeps content intact. Publishing copies the draft blocks live in the same call. " + SECTION_DOCTRINE,
3110
3173
  z.object({ pageId: z.string().min(1), title: z.string().optional(), slug: z.string().optional(), blockJson: z.array(blockObject).optional().describe("REPLACES the page's block composition"), metaTitle: z.string().optional(), metaDescription: z.string().optional(), status: z.enum(["draft", "published"]).optional() }).shape,
3111
- async (c, a) => ok("Updated page.", await data(c, "PATCH", `/management/pages/${s(a.pageId)}/meta`, { title: a.title, slug: a.slug, blockJson: a.blockJson, metaTitle: a.metaTitle, metaDescription: a.metaDescription, status: a.status }))
3174
+ async (c, a) => {
3175
+ const res = await c.fetchJSON(
3176
+ c.url(`/management/pages/${s(a.pageId)}/meta`),
3177
+ {
3178
+ method: "PATCH",
3179
+ body: JSON.stringify({ title: a.title, slug: a.slug, blockJson: a.blockJson, metaTitle: a.metaTitle, metaDescription: a.metaDescription, status: a.status })
3180
+ }
3181
+ );
3182
+ const note = res.redirectNote;
3183
+ return ok(
3184
+ note ? `Updated page. An existing redirect from ${note.source} already points to ${note.existingDestination}; it wasn't changed, so the old address does not redirect to the new one.` : "Updated page.",
3185
+ res.data
3186
+ );
3187
+ }
3112
3188
  ),
3113
3189
  // ── Code + deploy (parity with remote /mcp; needs artifact:write) ──
3114
3190
  // Both grant shapes carry that scope now; a workspace-wide one names its target per
@@ -3447,6 +3523,8 @@ function buildToolDefs(deps) {
3447
3523
  slug: args.slug,
3448
3524
  pageType: args.pageType ?? "singleton",
3449
3525
  ...args.blockJson ? { blockJson: args.blockJson } : {},
3526
+ // Cast: the SDK's ContentModelField types `options` as string[], narrower than the API,
3527
+ // which also takes {label, value} (backend selectFieldSchema).
3450
3528
  ...args.fields ? { fields: args.fields.map(toField) } : {},
3451
3529
  ...args.metaTitle !== void 0 ? { metaTitle: args.metaTitle } : {},
3452
3530
  ...args.metaDescription !== void 0 ? { metaDescription: args.metaDescription } : {}
@@ -3462,7 +3540,7 @@ function buildToolDefs(deps) {
3462
3540
  name: "create_content_model",
3463
3541
  config: {
3464
3542
  title: "Create a content model (reusable schema)",
3465
- description: "Create a content model \u2014 a reusable schema for a dynamic collection (Blog, Products, Testimonials). `fields` may NEST: type 'group' = one nested object of child fields; type 'repeater' = a repeatable array of child objects. Put child fields in each group/repeater's own `fields` (any depth). Pass kind:'block' to create a BLOCK type instead \u2014 see that argument.",
3543
+ description: "Create a content model \u2014 a reusable schema for a dynamic collection (Blog, Products, Testimonials). `fields` may NEST: type 'group' = one nested object of child fields; type 'repeater' = a repeatable array of child objects. Put child fields in each group/repeater's own `fields` (any depth). Pass kind:'block' to create a BLOCK type instead \u2014 see that argument. `fieldsets` ([{id, name}]) are the model's editor cards; a field joins one with `fieldsetId`. The slug must be unique in this project and branch: a taken one is refused with 409 `slug_taken` and free `suggestions`. " + RICH_TEXT_NOTE,
3466
3544
  inputSchema: createModelInput.shape
3467
3545
  },
3468
3546
  handler: guard(
@@ -3473,7 +3551,9 @@ function buildToolDefs(deps) {
3473
3551
  name: args.name,
3474
3552
  slug: args.slug,
3475
3553
  ...args.description !== void 0 ? { description: args.description } : {},
3554
+ ...args.urlPattern !== void 0 ? { urlPattern: args.urlPattern } : {},
3476
3555
  ...args.kind !== void 0 ? { kind: args.kind } : {},
3556
+ ...args.fieldsets !== void 0 ? { fieldsets: args.fieldsets } : {},
3477
3557
  fields: toFields(args.fields)
3478
3558
  });
3479
3559
  return ok(
@@ -3487,7 +3567,7 @@ function buildToolDefs(deps) {
3487
3567
  name: "add_field",
3488
3568
  config: {
3489
3569
  title: "Add a field to a content model",
3490
- description: "Append a field to an existing content model. Reads the model's current fields and adds yours (read-modify-write) \u2014 never removes existing fields. For a section/zone, add ONE 'group' (fixed block) or 'repeater' (repeating list) field carrying its child `fields` \u2014 don't add the zone's inner fields as separate top-level fields.",
3570
+ description: "Append a field to an existing content model. Reads the model's current fields and adds yours (read-modify-write) \u2014 never removes existing fields. For a section/zone, add ONE 'group' (fixed block) or 'repeater' (repeating list) field carrying its child `fields` \u2014 don't add the zone's inner fields as separate top-level fields. `fieldsetId` puts it in one of the model's fieldsets (set them with update_content_model). " + RICH_TEXT_NOTE,
3491
3571
  inputSchema: addFieldInput.shape
3492
3572
  },
3493
3573
  handler: guard(
@@ -3530,6 +3610,8 @@ function buildToolDefs(deps) {
3530
3610
  handler: guard(
3531
3611
  async (args) => withClient(async (client) => {
3532
3612
  const page = await client.addPageFields(args.pageId, {
3613
+ // Cast: same as create_page. The SDK types `options` as string[], narrower than the
3614
+ // API, which also takes {label, value} (backend selectFieldSchema).
3533
3615
  addFields: [toField(args)],
3534
3616
  ...args.after !== void 0 ? { after: args.after } : {}
3535
3617
  });
@@ -4053,6 +4135,61 @@ ${lines.join("\n")}`, found);
4053
4135
  })
4054
4136
  )
4055
4137
  },
4138
+ {
4139
+ name: "set_component_source",
4140
+ config: {
4141
+ title: "Record which file implements a component",
4142
+ description: "Call this RIGHT AFTER you write or locate the code for a component in the app's repository. It tells BetterCMS which file renders the component, so the dashboard's Output button can build a live preview and validate it with no setup. Without it Output says no source is recorded and nothing is validated. Record ONLY a file that renders exactly this component from its own fields: a component whose props are this component's field keys (`kind: 'file'`, the default), or a section the codemod extracted with `npx @bettercms-ai/convert --componentize` \u2014 a file starting `// @bettercms-ai/convert section` whose props are `{ blockId, bind, overrides, page }` (`kind: 'section'`). Never record a different component that merely contains this markup (e.g. a card that needs a `post` object): it cannot render from these fields and validation fails. If the markup is inline in a page, extract it into a section first. `path` is relative to the app root (e.g. 'src/components/sections/Hero.astro'); `export` is the export name, omitted for a default export. Call it again if the file moves; call clear_component_source if a recorded file is wrong.",
4143
+ inputSchema: setComponentSourceInput.shape
4144
+ },
4145
+ handler: guard(
4146
+ async (args) => withClient(async (client) => {
4147
+ const res = await client.fetchJSON(
4148
+ client.url(`/management/components/${encodeURIComponent(args.componentId)}/preview-source`),
4149
+ { method: "PUT", body: JSON.stringify({ path: args.path, ...args.export ? { export: args.export } : {}, ...args.kind ? { kind: args.kind } : {} }) }
4150
+ );
4151
+ return ok(`Recorded ${res.data.path}${res.data.export === "default" ? "" : ` (export ${res.data.export})`} as the source of component ${res.data.componentId}.`, res.data);
4152
+ })
4153
+ )
4154
+ },
4155
+ {
4156
+ name: "submit_componentize_receipt",
4157
+ config: {
4158
+ title: "Record the sources a componentize run wrote",
4159
+ description: "Record every section the componentize codemod extracted as its component's source. Call it right after `npx @bettercms-ai/convert --componentize --receipt componentize.json` (and after pushing the files): pass that receipt verbatim. Each extracted section is recorded as kind 'section' for the component with the same slug, so Output can build and validate it with no set_component_source per component. Answers which components were recorded, and any slug this project has no component for.",
4160
+ inputSchema: submitComponentizeReceiptInput.shape
4161
+ },
4162
+ handler: guard(
4163
+ async (args) => withClient(async (client) => {
4164
+ const res = await client.fetchJSON(
4165
+ client.url("/management/components/preview-sources/componentize-receipt"),
4166
+ { method: "POST", body: JSON.stringify({ receipt: args.receipt }) }
4167
+ );
4168
+ const { recorded, unknown, invalid } = res.data;
4169
+ return ok(
4170
+ `Recorded ${recorded.length} section source${recorded.length === 1 ? "" : "s"}${unknown.length ? `; no component for ${unknown.join(", ")}` : ""}${invalid.length ? `; unimportable path for ${invalid.join(", ")}` : ""}.`,
4171
+ res.data
4172
+ );
4173
+ })
4174
+ )
4175
+ },
4176
+ {
4177
+ name: "clear_component_source",
4178
+ config: {
4179
+ title: "Remove a component's recorded source file",
4180
+ description: "Remove the file recorded as a component's source (by set_component_source or a conversion receipt). Use it when the recorded file is wrong \u2014 Output then shows no source until the right file is recorded. Safe to call when nothing is recorded.",
4181
+ inputSchema: clearComponentSourceInput.shape
4182
+ },
4183
+ handler: guard(
4184
+ async (args) => withClient(async (client) => {
4185
+ const res = await client.fetchJSON(
4186
+ client.url(`/management/components/${encodeURIComponent(args.componentId)}/preview-source`),
4187
+ { method: "DELETE" }
4188
+ );
4189
+ return ok(res.data.cleared ? `Removed the recorded source of component ${res.data.componentId}.` : `Component ${res.data.componentId} had no recorded source.`, res.data);
4190
+ })
4191
+ )
4192
+ },
4056
4193
  {
4057
4194
  name: "publish_component",
4058
4195
  config: {
@@ -4621,6 +4758,19 @@ cluster \u2192 \`group\`. Nesting is capped at TWO levels from the prop (a \`tab
4621
4758
  utilities, aria attributes and alt text all survive. Never restyle while componentizing. Run
4622
4759
  \`componentize_sections { dryRun: true }\` and the codemod's \`--dry-run\` first.
4623
4760
 
4761
+ **5b. Every component's Output must render \u2014 record its SOURCE.** The dashboard's Output builds and
4762
+ validates a component from the file in the repository that renders it, and only a file whose props
4763
+ match renders it. A section the codemod extracted is such a file: after \`npx @bettercms-ai/convert
4764
+ --componentize --receipt componentize.json\`, push the files and call \`submit_componentize_receipt
4765
+ { receipt }\` \u2014 every extracted section is recorded for the component with the same slug. A section
4766
+ you write BY HAND follows the same contract: props exactly \`{ blockId, bind, overrides, page }\`, the
4767
+ root element carries \`data-bcms-block={blockId}\`, and copy is read through \`bcmsSection(page, bind,
4768
+ overrides)\` (or \`bcmsSectionPaths\` for a flat field family like \`wrap-h2-latest\`); then call
4769
+ \`set_component_source { kind: 'section' }\`. A component module whose props ARE its fields is
4770
+ \`set_component_source\` with the default kind. NEVER record a different component that merely
4771
+ contains the markup (a card needing a \`post\` object): it cannot render from these fields and its
4772
+ validation fails every time \u2014 \`clear_component_source\` removes a wrong one.
4773
+
4624
4774
  **6. The final validation is \`get_site_composition\`.** It is the receipt for "is every page
4625
4775
  assembled from registered components?": per page its blocks and every component placement with
4626
4776
  that component's status, plus site totals \u2014 unpublished, awaiting evidence, awaiting approval,
@@ -4635,6 +4785,8 @@ bulk approve, and no tool here can grant that approval. Finish with \`get_next_s
4635
4785
  1. get_site_composition the before picture
4636
4786
  2. get_componentize_plan DERIVED site only \u2014 confirm it with the user
4637
4787
  3. componentize_sections dryRun first, then for real
4788
+ 3b. npx @bettercms-ai/convert --componentize --receipt componentize.json, push,
4789
+ then submit_componentize_receipt { receipt } every section's Output source, ONE call
4638
4790
  4. create_components everything the plan did not cover, ONE call, each with a group
4639
4791
  5. compose_pages every page's blockJson, ONE call
4640
4792
  6. update_layout { commands } nav/footer chrome + add-component, ONE call