@bettercms-ai/mcp 0.59.1 → 0.60.0
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 +56 -39
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2268,15 +2268,19 @@ var uiObject = z.object({
|
|
|
2268
2268
|
collapsed: z.boolean().optional().describe("start this panel collapsed. Use it for long optional sections."),
|
|
2269
2269
|
preview: z.object({
|
|
2270
2270
|
title: z.string().min(1).optional().describe("CHILD KEY whose value titles a collapsed row"),
|
|
2271
|
+
subtitle: z.string().min(1).optional().describe("CHILD KEY whose value is the row's second line (e.g. 'level' \u2192 'Heading \xB7 h2')"),
|
|
2271
2272
|
media: z.string().min(1).optional().describe("CHILD KEY whose value is the row's thumbnail (image/file/url all work)")
|
|
2272
2273
|
}).optional().describe(
|
|
2273
|
-
"how ONE ITEM summarises itself when collapsed.
|
|
2274
|
+
"how ONE ITEM summarises itself when collapsed. Every slot names a CHILD KEY of THIS field/prop \u2014 not a value, not a dotted path. Without it a repeater of testimonials reads 'Item 1, Item 2, Item 3'; with {title:'author', media:'avatar'} it reads the names with their faces."
|
|
2274
2275
|
),
|
|
2275
2276
|
layout: z.enum(["list", "grid"]).optional().describe(
|
|
2276
2277
|
"how the ITEMS are arranged. Omit (\u2261 'list') for rows of text; 'grid' for items whose thumbnail is the thing you scan \u2014 a gallery, a logo wall, a team. Same placement rule as `preview`."
|
|
2277
2278
|
),
|
|
2278
2279
|
reorderable: z.boolean().optional().describe(
|
|
2279
2280
|
"omit (\u2261 true) unless the order is PART OF THE MEANING. `false` LOCKS the list \u2014 a 'three steps' band that must stay three steps in that order, a nav whose order is semantic, a timeline. Valid only where there is a list to lock."
|
|
2281
|
+
),
|
|
2282
|
+
control: z.enum(["segmented", "slider"]).optional().describe(
|
|
2283
|
+
"editor control for a LEAF. Omit for the default. 'segmented' on a select with a few short options (alignment, theme, size); 'slider' on a number that has config.min < config.max (opacity, columns). Anything else is refused with the rule named."
|
|
2280
2284
|
)
|
|
2281
2285
|
});
|
|
2282
2286
|
var fieldShape = {
|
|
@@ -2350,6 +2354,7 @@ var fieldsetsArg = z.array(
|
|
|
2350
2354
|
"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."
|
|
2351
2355
|
);
|
|
2352
2356
|
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.";
|
|
2357
|
+
var PAGES_NOT_COLLECTIONS_NOTE = "A collection is only for items one template route repeats (/blog/:slug: set urlPattern). A single page's copy (home, about, contact, pricing, legal) is page fields in sections (add_page_field / set_page_content), never a one-entry collection.";
|
|
2353
2358
|
function flatFieldKeys(fields) {
|
|
2354
2359
|
const out = [];
|
|
2355
2360
|
const childrenOf = (f) => {
|
|
@@ -2431,11 +2436,11 @@ function toField(f) {
|
|
|
2431
2436
|
...f.config ? { config: f.config } : {}
|
|
2432
2437
|
};
|
|
2433
2438
|
}
|
|
2434
|
-
var BRAND_KIT_NOTE = "`brandKit`
|
|
2439
|
+
var BRAND_KIT_NOTE = "`brandKit` (colors, typography, radius and any typeScale/spacing/elevation/motion/graphics) is edited in the BetterCMS dashboard AND by set_brand_assets and set_brand_graphics; every write is a version a person can restore. ASSETS: a media asset OF THIS PROJECT by id; null clears one; a slot a person chose needs `replace: true`. GRAPHICS: `kind`, optional `angle`, 2-8 stops naming `colors.*` or `extras` keys \u2014 never a CSS string and never a hex. CONSUME, DO NOT COPY: a hosted page exposes `--brand-color-<key>`, `--brand-shadow-<key>` and `--brand-gradient-<key>`; style sections with their `style` tokens (`nav.backgroundGradient` and `footer.backgroundGradient` take a gradient key), never copied values. Never invent brand facts: read them from get_project.";
|
|
2435
2440
|
var MODEL_IF_MATCH_NOTE = "optimisticVersion from get_content_model. When sent, the update applies only if the model is still at that version; a 409 means someone changed it since \u2014 read it again and re-apply, never retry blindly. Omitted, the last write wins.";
|
|
2436
2441
|
var ENTRY_META_NOTE = "SEO is native: set this entry's metaTitle, metaDescription, noindex, canonical, og, twitter, schemaType or schema in `meta` (never as model fields). `meta` MERGES into what is stored: a key you leave out is kept, null or an empty string clears it.";
|
|
2437
2442
|
var PAGE_HEAD_NOTE = "noindex, canonical, og and twitter set the page's head extras; each MERGES into what is stored (a key you leave out is kept, null or an empty string clears it).";
|
|
2438
|
-
var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state; URL folders (page folders) are set in the dashboard. Folders nest at most 4 levels deep. Icons are lucide
|
|
2443
|
+
var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state; URL folders (page folders) are set in the dashboard. Folders nest at most 4 levels deep. Icons are lucide names (a-z, 0-9, dashes); null clears one. SYSTEM FOLDERS "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist and can never be moved or deleted (400 NAV_SYSTEM_FOLDER); rename_folder and set_icon work on them. TYPE PURITY (400 NAV_TYPE_MISMATCH): Other pages holds only pages at any depth, Shared only collections, Settings neither \u2014 globals are not sidebar items. ACCESS: changing the structure needs Admin or Developer, reading only content access; a 403 SCHEMA_ACCESS_REQUIRED means neither: stop and tell the user, do not retry. PINS sit above the folders in their own order, pages or collections; in a document a pin is folderId "home:root", a virtual id with no folder record (a folder record with that id is 400 NAV_SYSTEM_FOLDER). set_content_structure never refuses a kind mismatch: it returns `warnings` and `kindMismatches` (the single writes do \u2014 see `kind`). PAGE-BOUND MODELS: a page with fields has a bound model that is NOT a collection: never listed or filed, and the writes refuse it with 400 NAV_PAGE_BOUND_MODEL naming its page. Organise the PAGE instead, by its page id. ';
|
|
2439
2444
|
var STRUCTURE_FENCE = "ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state, and nothing is rebuilt or delivered; URL folders (page folders) are set in the dashboard. Read the resource bettercms://playbook/structure before you organise anything: it holds the rules this refuses on \u2014 system folders, type purity, the Admin/Developer access check, root pins and page-bound models. A resource is never truncated; a long description is.";
|
|
2440
2445
|
function structureResult(d) {
|
|
2441
2446
|
const message = d?.message;
|
|
@@ -2443,18 +2448,18 @@ function structureResult(d) {
|
|
|
2443
2448
|
}
|
|
2444
2449
|
var GIT_TEXT = {
|
|
2445
2450
|
list_github_repos: "Every GitHub repository this workspace's connected GitHub App installations can reach, grouped by account. Use it to find the repository to import: each ACCOUNT carries the `installationId` that import_github_repo and fork_github_repo take, and each repository under it carries `owner`, `repo` and `defaultBranch`. An empty `accounts` list means nobody has connected GitHub to this workspace yet \u2014 tell the user to connect it from the dashboard (Project \u2192 Hosting \u2192 Connect GitHub) and call this again.",
|
|
2446
|
-
import_github_repo: "Connect an existing GitHub repository to the connected project
|
|
2451
|
+
import_github_repo: "Connect an existing GitHub repository to the connected project (the dashboard's Import from GitHub) \u2014 do it BEFORE writing any code for a user who already has a repo. It wires the project API key and variables into the repository, records the connection, assigns the site handle, imports the repo's bcms-content.json as content models when it has one, and queues the first build. Pass `installationId`, `owner` and `repo` from list_github_repos; `branch` defaults to the repository's own default branch and becomes the branch every deploy builds from. It only works on a repository this workspace's installations can already reach \u2014 for anyone else's (a public starter, a template, another account's site) use fork_github_repo. Read `warning` / `contentWarning` / `envWarning` back to the user verbatim when they come back (`envWarning` names the environment variables the repo documents in .env.example and the project lacks \u2014 the site renders empty or crashes until they are set) \u2014 the repository connected but cannot build, or will leave the Pages tab empty. Re-importing replaces the project's existing connection.",
|
|
2447
2452
|
fork_github_repo: "Fork ANY GitHub repository into the user's own account and import it in one step \u2014 how a user starts from a repository that is not theirs (a public starter, a template, another account's site). BetterCMS cannot wire a repository it has no installation on, so the copy has to live on their account first; this makes that copy, keeps the upstream link, and then connects it exactly as import_github_repo does. `installationId` names the DESTINATION account (from list_github_repos); `owner` and `repo` name the SOURCE, anywhere on GitHub. `name` renames the fork; `connect: false` forks without connecting. Forking is asynchronous: a 202 means the fork exists but GitHub is still copying it, and the answer tells you to call import_github_repo with the fork's owner and repo a moment later \u2014 never fork a second time.",
|
|
2448
2453
|
list_github_branches: "The connected repository's branches, plus `buildBranch` \u2014 the one the provisioned Action builds from. A commit on any other branch never reaches the live site, so check this before you push.",
|
|
2449
2454
|
list_github_commits: "The connected repository's commit history, newest first: sha, message, author, date and url. Defaults to the build branch; pass `branch` for another, `path` to narrow it to one file or directory, and `limit` (1-100, default 20). Read the head sha here before a push you want to be safe, and hand it back as push_to_github's `expectedHeadSha`.",
|
|
2450
|
-
push_to_github: "Commit files to the connected repository \u2014 a real git commit
|
|
2455
|
+
push_to_github: "Commit files to the connected repository \u2014 a real git commit written server-side by the GitHub App: no clone, remote or token of your own. Send `files` ([{path, content, encoding}]) and/or `deletePaths`, plus a `message`; both go into ONE commit, so a rename is atomic. It targets the BUILD branch by default, where the push starts the rebuild that goes live \u2014 poll get_deploy_status after it, and read the response's `note`, which says whether this push deploys. Pass `branch` to push somewhere else (a branch that does not exist yet is created from the build branch, or from `baseBranch`) and then open a pull request with create_github_pull_request. Pass `expectedHeadSha` from list_github_commits \u2014 a full sha or a prefix of one \u2014 to be refused with a 409 rather than silently overwrite a branch someone else moved while you worked. Limits, enforced BEFORE anything is written: 200 paths and 10 MB per commit, and `.git`, `.github/workflows`, `.env` and `.npmrc` are never writable \u2014 a refused patch writes nothing at all and names every offending path.",
|
|
2451
2456
|
create_github_pull_request: "Open a pull request on the connected repository. `head` is the branch you pushed; `base` defaults to the build branch, so merging it is what ships the change. Use this instead of pushing straight to the build branch whenever a human should review first, or when that branch is protected. Returns the number and the url to hand the user.",
|
|
2452
2457
|
list_github_pull_requests: "Pull requests on the connected repository: number, title, state, whether it merged, draft, head, base and url. `state` is 'open' (the default), 'closed' or 'all'. Poll it to find out whether the pull request you opened has landed."
|
|
2453
2458
|
};
|
|
2454
2459
|
var HOSTING_TEXT = {
|
|
2455
2460
|
list_env_vars: "The project's environment variables by NAME and scope (never a value), and `missing`: the ones the repository's .env.example declares that the project does not set. A site missing them builds and serves, then renders empty or crashes in the browser. Resolve `missing` by running `npx @bettercms-ai/cli@latest env push` in the repository: it uploads those values from the user's own .env files straight to BetterCMS, so they never pass through this conversation. Never read, print or ask the user to paste a value. When the values do not exist locally because the site reads its content from another CMS, move that content into BetterCMS instead (the bettercms skill's external-content reference). BetterCMS's own BCMS_* identifiers are supplied to every build and never need setting.",
|
|
2456
2461
|
list_hosting_connections: "The Vercel, Netlify and Cloudflare accounts connected to this workspace (id, provider, accountLabel, status; never a token), plus `connectUrl`, the dashboard page where a person connects one. Call it before deploy_to_host. If the provider the user wants has no connection with status `ok`, give the user `connectUrl` to connect it in their browser and stop until they have: you never handle OAuth or a host token yourself.",
|
|
2457
|
-
deploy_to_host: "Deploy this project to the user's own Vercel or Netlify account through their connection. BetterCMS builds the site and the host serves that build
|
|
2462
|
+
deploy_to_host: "Deploy this project to the user's own Vercel or Netlify account through their connection. BetterCMS builds the site and the host serves that build: no host CLI, no token to paste, no vercel.json rewrite; forms keep posting to the BetterCMS API. The first call creates the site on the host and makes it the project's host; a later call for the same provider redeploys that site (`redeployed: true`) instead of creating another. `connectionId` (from list_hosting_connections) defaults to the newest working one, `siteName` to the project's handle. A refusal carries a `code` and a `fixUrl` (not_connected, reconnect, pick_existing_site, grant_required, ADMIN_REQUIRED): each is fixed by the user at `fixUrl`, so relay it and stop. `deploymentQueued: false` means the project has no live build yet: deploy_project first, and that release reaches the host on its own. Never run the Vercel or Netlify CLI while a connection exists. Then poll get_host_deploy_status. Needs artifact:write, like deploy_project.",
|
|
2458
2463
|
get_host_deploy_status: "The connected project's recent deployments to Vercel, Netlify or Cloudflare, newest first: state (queued|deploying|live|failed), providerUrl, error, sha and targetName. Poll it after deploy_to_host until the newest one is `live`, whose `providerUrl` is the live URL to hand the user, or `failed`, whose `error` you read back to them."
|
|
2459
2464
|
};
|
|
2460
2465
|
function ok(summary, data) {
|
|
@@ -2754,7 +2759,8 @@ function buildToolDefs(deps) {
|
|
|
2754
2759
|
data: z.record(z.string(), z.unknown()).describe(
|
|
2755
2760
|
"field values keyed by field key. A nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value, \u2026 }, repeatable: [ { childKey: value }, \u2026 ] } \u2014 nonRepeatable holds the fixed-block values, repeatable is the list of item objects (omit a zone you didn't define). A primitive 'array' (itemType) is a plain list. An 'image' value is an asset URL or asset id (from upload_asset) \u2014 the server resolves it to { id, url, name, altText }. Read get_page first to see each field's zones."
|
|
2756
2761
|
),
|
|
2757
|
-
status: z.enum(["draft", "published"]).optional().describe("omit to leave status unchanged")
|
|
2762
|
+
status: z.enum(["draft", "published"]).optional().describe("omit to leave status unchanged"),
|
|
2763
|
+
replace: z.boolean().optional().describe("true replaces the whole entry data, removing omitted fields; default false merges by top-level key")
|
|
2758
2764
|
});
|
|
2759
2765
|
const getEntryInput = z.object({
|
|
2760
2766
|
entryId: z.string().min(1).describe("content entry id")
|
|
@@ -3248,7 +3254,7 @@ ${d.summary.outline}` : "Content structure.", d);
|
|
|
3248
3254
|
def(
|
|
3249
3255
|
"suggest_content_structure",
|
|
3250
3256
|
"Propose the standard Content structure",
|
|
3251
|
-
`READ-ONLY: propose the standard Content sidebar structure for the connected project (${STRUCTURE_PLAYBOOK_URI}) and write NOTHING. Runs the
|
|
3257
|
+
`READ-ONLY: propose the standard Content sidebar structure for the connected project (${STRUCTURE_PLAYBOOK_URI}) and write NOTHING. Runs the standard over the project's pages, collections, references and built routes: the home page and each collection's list page pinned, one folder per content family named for its detail type (Blog posts, Case studies, Products with its taxonomies and an Attributes subfolder), ordered by entry count (or the caller's nav order), shared taxonomies in Shared, every remaining page in Other pages, at most 2 levels deep. Page-bound models are never filed. Returns { doc, version, hasDocument, outline, rationale }. To apply it: show the user the outline, then call set_content_structure with this doc and this version (the If-Match; a 412 means someone changed the sidebar, so suggest again). When hasDocument is true the project already has a structure somebody made: never replace it without the user's yes; file only what is new with move_to_folder instead.`,
|
|
3252
3258
|
z.object({
|
|
3253
3259
|
maxDepth: z.number().int().min(1).max(4).optional().describe("folder nesting to propose; default 2 (the standard), 4 is the hard limit")
|
|
3254
3260
|
}).shape,
|
|
@@ -3321,7 +3327,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3321
3327
|
def(
|
|
3322
3328
|
"set_brand_assets",
|
|
3323
3329
|
"Point the brand's logo or favicon at a media asset",
|
|
3324
|
-
`Point the brand's logo (mark) or favicon at a media asset of this project
|
|
3330
|
+
`Point the brand's logo (mark) or favicon at a media asset of this project \u2014 upload it to this project's media library first if it is not there. A slot a PERSON chose is refused without replace: true, and the refusal says so. ${BRAND_KIT_NOTE}`,
|
|
3325
3331
|
z.object({
|
|
3326
3332
|
mark: z.string().min(1).nullable().optional().describe("media asset id for the logo; null clears it"),
|
|
3327
3333
|
favicon: z.string().min(1).nullable().optional().describe("media asset id for the favicon; null clears it"),
|
|
@@ -3332,7 +3338,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3332
3338
|
def(
|
|
3333
3339
|
"set_brand_graphics",
|
|
3334
3340
|
"Add, replace or remove the brand's gradients",
|
|
3335
|
-
`Add, replace or remove the brand's gradients \u2014 the blobs and washes a site uses as backgrounds. They are TOKENS, not media
|
|
3341
|
+
`Add, replace or remove the brand's gradients \u2014 the blobs and washes a site uses as backgrounds. They are TOKENS, not media. A stop naming a colour the kit does not have is refused; add it to \`extras\` first. Upsert matches BY KEY. ${BRAND_KIT_NOTE}`,
|
|
3336
3342
|
z.object({
|
|
3337
3343
|
upsert: z.array(z.object({
|
|
3338
3344
|
key: z.string().min(1).describe("token key: lowercase letters, digits and hyphens. Emitted as --brand-gradient-<key>"),
|
|
@@ -3541,7 +3547,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3541
3547
|
def(
|
|
3542
3548
|
"set_binding_mode",
|
|
3543
3549
|
"Set how the site's bindings are resolved",
|
|
3544
|
-
"Switch this project between the two binding resolvers, from the NEXT release on. `declaredBindings: true` makes the annotator trust the template's own data-bcms-field / data-bcms-props and never guess from rendered text \u2014 the durable state; `false` returns to text-matching, which works once (at import, when the CMS values equal the built copy) and breaks the first time anyone edits a value. Call it ONLY after every page's copy is declared in the template: undeclared fields stop being editable. The order is push \u2192 release \u2192 get_binding_report shows mode 'text-match' with 0 unmatched \u2192 set_binding_mode \u2192 release again \u2192 get_binding_report shows mode 'declared'. Flipping back is the same call. REQUIRES
|
|
3550
|
+
"Switch this project between the two binding resolvers, from the NEXT release on. `declaredBindings: true` makes the annotator trust the template's own data-bcms-field / data-bcms-props and never guess from rendered text \u2014 the durable state; `false` returns to text-matching, which works once (at import, when the CMS values equal the built copy) and breaks the first time anyone edits a value. Call it ONLY after every page's copy is declared in the template: undeclared fields stop being editable. The order is push \u2192 release \u2192 get_binding_report shows mode 'text-match' with 0 unmatched \u2192 set_binding_mode \u2192 release again \u2192 get_binding_report shows mode 'declared'. Flipping back is the same call. REQUIRES artifact:write (the authority that deploys the site): this decides what every future release does to every page. On a workspace-wide connection pass `projectId`: the scope is there, but the human who authorized the connection must be able to publish THAT project or it is refused with 403 PROJECT_PUBLISH_DENIED. Playbook section 13.",
|
|
3545
3551
|
z.object({ declaredBindings: z.boolean().describe("true = trust the template's declared bindings; false = text-match (the default)") }).shape,
|
|
3546
3552
|
async (c, a) => ok("Recorded the binding mode.", await data(c, "PATCH", `/management/projects/current/binding-mode`, { declaredBindings: a.declaredBindings }))
|
|
3547
3553
|
),
|
|
@@ -3566,10 +3572,10 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3566
3572
|
def(
|
|
3567
3573
|
"submit_conversion_receipt",
|
|
3568
3574
|
"Record what the conversion codemod could and could not do",
|
|
3569
|
-
"Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert
|
|
3575
|
+
"Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert --receipt out.json` wrote as `{ briefDigest, receipt }`, for the SAME `briefDigest` get_conversion_brief { complete: true } returned (its shape: see `receipt`). \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: the response answers `complete` (`sourceComplete` is the receipt's own verdict; `liveChecks` gives the last release's `structureUnstamped` route count and `reportSha`) and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`, `action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; `snippet` and `why` say what to change there. On a `declare-richtext` with `kind: document`, bind the ONE element wrapping every block of the Body, never the paragraph holding its first. Apply every fix, rerun, resubmit: only `complete: true` ends a conversion \u2014 never report a site converted on less. It is a RECORD, not a release: nothing on the site changes; the meter reads it on the get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534} SUBMIT THE `--forms` RECEIPT TOO, AS A SECOND CALL: a `--forms` run's receipt has all-zero `paths` and a `forms` block; write it to its own file (`--receipt forms-receipt.json`) and submit it under the same `briefDigest` (stored beside the binding receipt, never counted in coverage), then publish every form its `forms.pending` names. \u{1F534} THE SITE'S DESIGN IS NOT YOURS TO CHANGE: run `npx @bettercms-ai/convert --verify-styles --base <commit before the conversion> --root .` and include its `styleEdits` in the receipt; one listing any changed class, className or style is refused with 409 `style-edits` naming every token \u2014 restore them. Requires artifact:write.",
|
|
3570
3576
|
z.object({
|
|
3571
3577
|
briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
|
|
3572
|
-
receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim.")
|
|
3578
|
+
receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim: `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }` plus `styleEdits`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length; each pending `reason` is the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026); a path with no row reads `not-declared`.")
|
|
3573
3579
|
}).shape,
|
|
3574
3580
|
async (c, a) => ok("Recorded the conversion receipt.", await data(c, "POST", `/management/projects/current/conversion-receipt`, { briefDigest: a.briefDigest, receipt: a.receipt }))
|
|
3575
3581
|
),
|
|
@@ -3664,7 +3670,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3664
3670
|
def(
|
|
3665
3671
|
"deploy_project",
|
|
3666
3672
|
"Deploy new source/build",
|
|
3667
|
-
"Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`: SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git
|
|
3673
|
+
"Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`: SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git and build output/caches (dist, build, .next, .astro, .cache) from the archive: the source is reinstalled and built server-side, and the ~100 MB upload ceiling is enforced in transit (a 413/502 with no server-side detail) \u2014 server-side stripping runs AFTER the upload and cannot rescue an over-limit body. For a LARGE archive (or a 413/502) use create_deploy_upload + deploy_from_upload, which uploads straight to storage with no size ceiling. Returns `canvas.lane` (the editor's live-preview lane: `bridge` / `draft-route` / `none`; playbook section 11) and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
|
|
3668
3674
|
z.object({ data: z.string().min(1).describe("base64 .tgz/.zip of the project"), mimeType: z.string().optional() }).shape,
|
|
3669
3675
|
async (c, a) => ok("Deploy queued.", await raw(c, `/management/projects/deploy`, s(a.data), a.mimeType))
|
|
3670
3676
|
),
|
|
@@ -3803,14 +3809,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3803
3809
|
def(
|
|
3804
3810
|
"get_binding_report",
|
|
3805
3811
|
"Check what on the live site is editable",
|
|
3806
|
-
"The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns
|
|
3807
|
-
z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in.") }).shape,
|
|
3812
|
+
"The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; per slot this returns `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), what was bound, and `unmatched` \u2014 each path with its reason (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one, redeploy or not (verify it by fetching it); `outdated` \u2014 the next release rewrites it. It certifies only that every non-empty field has SOME element carrying its path; it cannot see copy that was never modelled, so diff each route's visible text against its entry values before calling a page done. `unaddressable` is that measure: visible text on the live site no field owns, measured server-side on every inspected page each release, by route and bucket, biggest first. Over `count / visible > 0.02` is copy the CMS cannot see: fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook section 11. `builtRoutes` lists the routes the live build has HTML for (null = cannot say). `canvas.lane` is the editor's live-preview lane \u2014 `bridge`, `draft-route` or `none` (then get_next_steps carries the recipe). `skipReasons` says why pages were not inspected; `coverage.error` = `nothing-bound` means no element of this build carries a binding, so the percentage beside it describes a site this build is not.",
|
|
3813
|
+
z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in. The response's `sha` is the build the report describes; `refreshRequired` is true when that is not the build this slot serves.") }).shape,
|
|
3808
3814
|
async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
|
|
3809
3815
|
),
|
|
3810
3816
|
def(
|
|
3811
3817
|
"get_conversion_brief",
|
|
3812
3818
|
"Get the brief for making this site's bindings durable",
|
|
3813
|
-
"The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route,
|
|
3819
|
+
"The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, every bindable field path with its `label`, `kind`, the CMS value (`current`) and the copy the repo renders today (`original`), the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_page_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane`: 'git-connected' = pull_project_source returns a repo; 'archive' = a tarball. Full recipe: section 13 of bettercms://playbook/schema; get_binding_report is the receipt that says you finished. Pass `complete: true` before running the CODEMOD (`npx @bettercms-ai/convert`): the brief UNCAPPED and paged, its full path list pinned under a `briefDigest` \u2014 what the coverage meter measures the build against. The complete brief also carries `forms` (id, status, fields, submitUrl), which `npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json` wires into the repository's own <form> elements after the binding pass; read its `forms.pending` and publish every form its `forms.notes` names in the Forms tab (a draft form rejects every submission with a 403).",
|
|
3814
3820
|
z.object({
|
|
3815
3821
|
complete: z.boolean().optional().describe("true = the COMPLETE brief for a codemod: nothing truncated, no page omitted, paged 50 pages at a time. Page 1 pins the path list the coverage meter measures against."),
|
|
3816
3822
|
cursor: z.string().optional().describe("The `cursor` from the previous page. Implies complete. A 409 BRIEF_CHANGED means the brief was re-derived mid-pagination \u2014 start again with no cursor.")
|
|
@@ -3820,21 +3826,21 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3820
3826
|
def(
|
|
3821
3827
|
"get_conversion_plan",
|
|
3822
3828
|
"Get the approved conversion to apply",
|
|
3823
|
-
"The APPROVED conversion a human reviewed in the BetterCMS dashboard: the exact new contents of each template file, already checked against this project's real field paths. Apply it instead of writing the bindings by hand. Check out `baseHeadOid` (the exact commit it was written against
|
|
3829
|
+
"The APPROVED conversion a human reviewed in the BetterCMS dashboard: the exact new contents of each template file, already checked against this project's real field paths. Apply it instead of writing the bindings by hand. Check out `baseHeadOid` (the exact commit it was written against), branch from there, write each file's `content` verbatim (whole file, no merge, no reformatting), then push or deploy however this project ships. `stale.head` / `stale.brief` say the repository or the CMS moved since it was approved: stop and ask for a fresh proposal. `receipt.paths.pending` lists every field the codemod could NOT bind, each with a reason (`DIALECT_UNSUPPORTED`, `PROP_TARGET_NOT_FOUND`, `REPEATER_FIXED_LENGTH`, \u2026) \u2014 apply the plan first, then bind those by hand. Then finish as a hand conversion does: get_binding_report until `unmatched` is empty, then set_binding_mode { declaredBindings: true } and release once more. 404 with `code: \"no-approved-plan\"` means nobody has approved one: use get_conversion_brief and do the conversion yourself. Requires artifact:write; on a workspace-wide connection pass `projectId`.",
|
|
3824
3830
|
z.object({}).shape,
|
|
3825
3831
|
async (c) => ok("Approved conversion plan.", await data(c, "GET", `/management/projects/current/conversion-plan`))
|
|
3826
3832
|
),
|
|
3827
3833
|
def(
|
|
3828
3834
|
"get_site_composition",
|
|
3829
3835
|
"Check every page is assembled from registered components",
|
|
3830
|
-
"The receipt for 'is every page assembled from registered components?' \u2014 read-only, computed live
|
|
3836
|
+
"The receipt for 'is every page assembled from registered components?' \u2014 read-only, computed live. Call it before a whole-site componentize run (playbook \xA712) and after it, as the proof. Per page: `kind` ('blocks' | 'fields-only' | 'empty'), a block census, `nonComponentBlocks` (loose TOP-LEVEL bands the editor's section lane cannot outline), and every `component` placement with the component's `status` ('published' | 'draft' | 'missing' \u2014 a draft renders as an EMPTY STRING on the live site), whether it is `inPicker` (a component with no sectionType is dropped from 'Add a section'), and any `emptyRequiredProps` \u2014 `required` is not enforced on write, so only this read reports it. Site-wide: `components` counts published/draft, `awaitingEvidence` and `awaitingApproval` (a HUMAN approves in the BetterCMS dashboard \u2014 no tool here can, and there is no bulk approve), plus `notInPicker` and `unused` ids; `layout.placements` is the chrome placed in the Global Layout draft. `verdict.everyPageComposed` with `verdict.reasons` is the answer to give the user. Pages are read DRAFT-inclusive: check each page's own `status` before claiming the site is live.",
|
|
3831
3837
|
z.object({}).shape,
|
|
3832
3838
|
async (c) => ok("Site composition.", await data(c, "GET", `/management/projects/current/composition`))
|
|
3833
3839
|
),
|
|
3834
3840
|
def(
|
|
3835
3841
|
"get_componentize_plan",
|
|
3836
3842
|
"Get the plan for turning this site's sections into components",
|
|
3837
|
-
"What this site's SECTIONS would become as components \u2014 a proposal that creates nothing
|
|
3843
|
+
"What this site's SECTIONS would become as components \u2014 a proposal that creates nothing and is computed live. For a site whose pages were DERIVED at import, each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import folded (`group-*`). Per page: each section's `groupKey`, `sectionType` family, leaf `fields` with their CMS values, `shapeHash`, and either the component already rendering it (`reuse.componentId`) or the proposed one (`reuse.proposedSlug`). Under `components`, a NEW one carries the exact `props` and `blockJson` create_component would take; an EXISTING one only `componentId`, `slug` and `name` (nothing is written for it). Every page gets its OWN components; a locale copy (`/fr`) reuses its default-locale page's. A COLLECTION TEMPLATE (dynamic page, `pattern: \"{slug}\"`) is planned ONCE, every prop reading the rendered entry: only its prose body and object-row lists are bands; frontmatter (author, images, tags, dates) stays entry fields. `pending` says why a group is not offered: `NO_GROUP_ROOT` (keys are still derive-lane names like `h1-welcome` \u2014 rename them into families first), `NOT_A_SECTION` (a lone scalar or the page's own metadata; `detail: ENTRY_METADATA` = collection-template frontmatter, `detail: DECLINED` = declined with componentize_sections `decline`), `IMAGE_AS_TEXT` (a text field holding an image path \u2014 make it an image field), `ALREADY_COMPONENTIZED`, `EMPTY_GROUP`, `NESTED_REPEATER` (three deep; two levels become a nested `table` sub-field), `PAGE_NOT_EMPTY` (blocks this lane does not own and will not overwrite). Chrome (`nav-`/`footer-` keys, anything promoted into the Layout) is NEVER a section: edit it through the Layout. READ `summary: true` FIRST \u2014 the full plan runs to hundreds of KB; `paged` pages it (409 PLAN_CHANGED = start again); the unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
|
|
3838
3844
|
z.object({
|
|
3839
3845
|
summary: z.boolean().optional().describe("true = no values: each section's groupKey, family, pending and component slug. Read this first."),
|
|
3840
3846
|
paged: z.boolean().optional().describe("true = 10 pages at a time; the response carries `page`, `pageCount` and a `cursor` for the next."),
|
|
@@ -3845,7 +3851,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3845
3851
|
def(
|
|
3846
3852
|
"componentize_sections",
|
|
3847
3853
|
"Turn this site's derived sections into components",
|
|
3848
|
-
"Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections
|
|
3854
|
+
"Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections, how many components it will create and which pages it will rewrite; `dryRun` first. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with ordered `component` instances, one per group, each carrying `props.bind: \"<groupKey>\"` \u2014 the page field group that already holds the copy. Nothing is copied or moved: the page keeps its `fields`, values and bindings, so click-to-edit and the coverage meter do not change (`copy: \"instance\"` moves the words onto each placement instead \u2014 see `copy`). Pass the plan's `digest`; a 409 `stale-plan` means the site changed: read the plan again, show the user what changed and confirm again. Read `components.wouldDuplicate` before applying: each id is an existing same-family component this run would sit a NEW one beside, because reuse is by identity and slug, never by family. Re-running is safe: an already-placed group returns in `sections.pending` as ALREADY_COMPONENTIZED, with no second component. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages, then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. Every component it CREATES is filed into a Component Group (see `group`). When the user says a proposed group is NOT a section, pass `decline: true` with those `sections` (and `pageIds`): nothing is componentized, the plan reports them `NOT_A_SECTION` / `DECLINED` and stops proposing them, and a plan that proposes nothing undeclined is done; `decline: false` offers them again.",
|
|
3849
3855
|
z.object({
|
|
3850
3856
|
digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
|
|
3851
3857
|
pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
|
|
@@ -3853,7 +3859,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3853
3859
|
sections: z.array(z.string().min(1)).optional().describe("Act only on these section groupKeys (from the plan). Every other section on the page keeps the placement it already has."),
|
|
3854
3860
|
copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy. 'bind' (default) leaves it in the page fields. 'instance' is the page-builder model: each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words."),
|
|
3855
3861
|
dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first."),
|
|
3856
|
-
group: z.string().min(1).max(100).optional().describe("File the components this run creates into this Component Group (by NAME
|
|
3862
|
+
group: z.string().min(1).max(100).optional().describe("File the components this run creates into this Component Group (a folder of the dashboard's Components tab; by NAME, created when missing), else its section family: nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A reused component keeps its Group; the receipt's `components.groups` lists the Groups filed into."),
|
|
3857
3863
|
decline: z.boolean().optional().describe("true = record `sections` as NOT sections (nothing is componentized; the plan stops proposing them). false = offer them again. Needs `sections`.")
|
|
3858
3864
|
}).shape,
|
|
3859
3865
|
async (c, a) => ok("Componentized the sections.", await data(c, "POST", `/management/projects/current/componentize`, { digest: a.digest, pageIds: a.pageIds, pages: a.pages, sections: a.sections, copy: a.copy, dryRun: a.dryRun, group: a.group, decline: a.decline }))
|
|
@@ -4168,7 +4174,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4168
4174
|
def(
|
|
4169
4175
|
"get_deploy_status",
|
|
4170
4176
|
"Get deploy/build status",
|
|
4171
|
-
"Get the connected project's deploy/build status: state (idle|queued|building|categorizing|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `categorizing` means the bytes are live but the import's setup lanes (content, pages, bindings) are still running, so `publishing` is still true and the site is not ready to edit yet. Poll this after deploy_project until state is idle with your sha live. Also returns `canvas.lane` (the editor's live-preview lane for this site: `bridge` / `draft-route` / `none`) and `editorUrl`, the visual editor to hand the user.",
|
|
4177
|
+
"Get the connected project's deploy/build status: state (idle|queued|building|categorizing|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `categorizing` means the bytes are live but the import's setup lanes (content, pages, bindings) are still running, so `publishing` is still true and the site is not ready to edit yet. Poll this after deploy_project until state is idle with your sha live. `release` is the newest release (id, sha, status, phaseMarks, error, createdAt): while an uploaded build ingests or releases, state is `building`, `release.sha` the new sha and `lastLiveSha` still the old one; a failed deploy shows `release.status` failed with its `error`, and `held` state (`held.by`: staff, archived or no-subdomain) means the release waits until that is cleared. Also returns `canvas.lane` (the editor's live-preview lane for this site: `bridge` / `draft-route` / `none`) and `editorUrl`, the visual editor to hand the user.",
|
|
4172
4178
|
z.object({}).shape,
|
|
4173
4179
|
async (c) => ok("Deploy status.", await data(c, "GET", `/management/projects/deploy-status`))
|
|
4174
4180
|
),
|
|
@@ -4289,7 +4295,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4289
4295
|
name: "create_content_model",
|
|
4290
4296
|
config: {
|
|
4291
4297
|
title: "Create a content model (reusable schema)",
|
|
4292
|
-
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,
|
|
4298
|
+
description: "Create a content model \u2014 a reusable schema for a dynamic collection (Blog, Products, Testimonials). " + PAGES_NOT_COLLECTIONS_NOTE + " `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,
|
|
4293
4299
|
inputSchema: createModelInput.shape
|
|
4294
4300
|
},
|
|
4295
4301
|
handler: guard(
|
|
@@ -4353,7 +4359,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4353
4359
|
name: "add_page_field",
|
|
4354
4360
|
config: {
|
|
4355
4361
|
title: "Add a field to a page",
|
|
4356
|
-
description: "Append a field to an existing page's schema (Home, About, a blog template, etc.). Additive \u2014 the API rejects a key that already exists and never overwrites or retypes existing fields. Use this when the target is a page (singleton or dynamic); use add_field when the target is a content model. 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.",
|
|
4362
|
+
description: "Append a field to an existing page's schema (Home, About, a blog template, etc.). Additive \u2014 the API rejects a key that already exists and never overwrites or retypes existing fields. Use this when the target is a page (singleton or dynamic); use add_field when the target is a content model. 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. A 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section.",
|
|
4357
4363
|
inputSchema: addPageFieldInput.shape
|
|
4358
4364
|
},
|
|
4359
4365
|
handler: guard(
|
|
@@ -4375,7 +4381,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4375
4381
|
name: "create_content_entry",
|
|
4376
4382
|
config: {
|
|
4377
4383
|
title: "Create a content entry",
|
|
4378
|
-
description: "Create a content entry under a model. Pass its field VALUES in `data`, keyed by field key \u2014 INCLUDE ALL REQUIRED FIELDS (create validates them). New entries are drafts; pass status:'published' to take it live. Read get_content_model first for the field keys and which are required.",
|
|
4384
|
+
description: "Create a content entry under a model. " + PAGES_NOT_COLLECTIONS_NOTE + " Pass its field VALUES in `data`, keyed by field key \u2014 INCLUDE ALL REQUIRED FIELDS (create validates them). New entries are drafts; pass status:'published' to take it live. Read get_content_model first for the field keys and which are required.",
|
|
4379
4385
|
inputSchema: createEntryInput.shape
|
|
4380
4386
|
},
|
|
4381
4387
|
handler: guard(
|
|
@@ -4399,14 +4405,15 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4399
4405
|
name: "set_page_content",
|
|
4400
4406
|
config: {
|
|
4401
4407
|
title: "Set a page's field values (content)",
|
|
4402
|
-
description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_content_entry is for dynamic collections only.",
|
|
4408
|
+
description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_content_entry is for dynamic collections only. Merges by top-level key (a zone value replaces that whole zone); pass replace:true to remove omitted fields.",
|
|
4403
4409
|
inputSchema: setPageContentInput.shape
|
|
4404
4410
|
},
|
|
4405
4411
|
handler: guard(
|
|
4406
4412
|
async (args) => withClient(async (client) => {
|
|
4407
4413
|
const entry = await client.setPageContent(args.pageId, {
|
|
4408
4414
|
data: args.data,
|
|
4409
|
-
...args.status !== void 0 ? { status: args.status } : {}
|
|
4415
|
+
...args.status !== void 0 ? { status: args.status } : {},
|
|
4416
|
+
...args.replace !== void 0 ? { replace: args.replace } : {}
|
|
4410
4417
|
});
|
|
4411
4418
|
return ok(
|
|
4412
4419
|
`Set content on page ${args.pageId} (entry ${entry.id}, status ${entry.status}).`,
|
|
@@ -4743,7 +4750,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4743
4750
|
name: "create_components",
|
|
4744
4751
|
config: {
|
|
4745
4752
|
title: "Create many components in one call",
|
|
4746
|
-
description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component
|
|
4753
|
+
description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component for more than three: a whole-site componentize run (playbook \xA712) is dozens. Each item reports its own `{ ok, id, slug, error }`: a failure does NOT stop the run and the successful rows stay, so read the receipt and retry only the failures (a 409 on `slug` means that name is taken \u2014 change it, do not re-run the batch). Every component lands as a DRAFT, exactly as create_component does \u2014 it renders as NOTHING on the live site until it is published, which needs the owner's approval in the dashboard. Pass `group` on each item to file it into the folder editors browse. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a declared prop TARGETS are click-to-edit; copy no prop points at is unreachable from canvas and dock alike. A page is composed of SECTIONS; the full doctrine is in the bettercms://playbook/schema resource, \xA712.",
|
|
4747
4754
|
inputSchema: createComponentsInput.shape
|
|
4748
4755
|
},
|
|
4749
4756
|
handler: guard(
|
|
@@ -4769,7 +4776,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4769
4776
|
name: "publish_components",
|
|
4770
4777
|
config: {
|
|
4771
4778
|
title: "Publish many components in one call",
|
|
4772
|
-
description: "Publish up to 100 components in ONE call \u2014 the same act as publish_component, once per item, copying each DRAFT definition to the live copy. Each item reports `{ ok, status, error, readiness }` and a failure never stops the run. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it, and it has no bulk form. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published.
|
|
4779
|
+
description: "Publish up to 100 components in ONE call \u2014 the same act as publish_component, once per item, copying each DRAFT definition to the live copy. Each item reports `{ ok, status, error, readiness }` and a failure never stops the run. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it, and it has no bulk form. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published. The item's `readiness` names what is missing \u2014 relay that to the user as the list of what only they can approve. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish; 422 means the publish would break a Layout, and the item names which.",
|
|
4773
4780
|
inputSchema: publishComponentsInput.shape
|
|
4774
4781
|
},
|
|
4775
4782
|
handler: guard(
|
|
@@ -4804,7 +4811,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4804
4811
|
name: "compose_pages",
|
|
4805
4812
|
config: {
|
|
4806
4813
|
title: "Set many pages' block composition in one call",
|
|
4807
|
-
description: "Give up to 50 pages their block composition in ONE call \u2014 the same write as update_page's `blockJson`, once per item, and it REPLACES each page's whole block array. Address a page by `pageId` or by `slug`. Each item reports `{ ok, pageId, slug, error }` and a failure does not stop the run. BEFORE writing anything it reads this project's component catalogue once and REFUSES any item placing a componentId the project does not have \u2014
|
|
4814
|
+
description: "Give up to 50 pages their block composition in ONE call \u2014 the same write as update_page's `blockJson`, once per item, and it REPLACES each page's whole block array. Address a page by `pageId` or by `slug`. Each item reports `{ ok, pageId, slug, error }` and a failure does not stop the run. BEFORE writing anything it reads this project's component catalogue once and REFUSES any item placing a componentId the project does not have \u2014 its missing sections would render as empty strings with no error anywhere. Writes DRAFTS: publish each page with update_page status:'published' afterwards. A page is composed of SECTIONS: a recurring band is a component with a `sectionType`, a one-off band is a `section` block whose `props.children` hold its blocks \u2014 never loose top-level heading/text/image blocks, which no editor can move, name or swap as a unit. The full doctrine is in the bettercms://playbook/schema resource, \xA712.",
|
|
4808
4815
|
inputSchema: composePagesInput.shape
|
|
4809
4816
|
},
|
|
4810
4817
|
handler: guard(
|
|
@@ -4908,7 +4915,7 @@ ${lines.join("\n")}`, found);
|
|
|
4908
4915
|
name: "set_component_source",
|
|
4909
4916
|
config: {
|
|
4910
4917
|
title: "Record which file implements a component",
|
|
4911
|
-
description: "Call this RIGHT AFTER you write or locate
|
|
4918
|
+
description: "Call this RIGHT AFTER you write or locate a component's code in the app's repository: it records which file renders it, so the dashboard's Output button can preview and validate it with no setup (without it 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.",
|
|
4912
4919
|
inputSchema: setComponentSourceInput.shape
|
|
4913
4920
|
},
|
|
4914
4921
|
handler: guard(
|
|
@@ -5013,7 +5020,7 @@ ${lines.join("\n")}`, found);
|
|
|
5013
5020
|
name: "publish_component",
|
|
5014
5021
|
config: {
|
|
5015
5022
|
title: "Publish a component",
|
|
5016
|
-
description: "Publish one project-scoped component variant after its exact implementation evidence and human approval are ready: copy its DRAFT definition to the live copy and re-bake every published page that embeds it. This is the ONLY way a component reaches the live site. Built-in `builtin:*` rows are locked blueprints, not implementations; materialize one with create_component first. Workspace-global component publication is currently fail-closed. create_component
|
|
5023
|
+
description: "Publish one project-scoped component variant after its exact implementation evidence and human approval are ready: copy its DRAFT definition to the live copy and re-bake every published page that embeds it. This is the ONLY way a component reaches the live site. Built-in `builtin:*` rows are locked blueprints, not implementations; materialize one with create_component first. Workspace-global component publication is currently fail-closed. Drafts (create_component, update_component) render as NOTHING live, with no error: publish every component you place on a page. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish: say so and let the user publish from the dashboard. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published. Its `readiness` names what is missing \u2014 relay it to the user instead of retrying or routing around it. 422 means publishing would break a Layout that uses it \u2014 the response names which.",
|
|
5017
5024
|
inputSchema: getComponentInput.shape
|
|
5018
5025
|
},
|
|
5019
5026
|
handler: guard(
|
|
@@ -5170,7 +5177,10 @@ DERIVED at import, where the componentize lane in \xA710 reuses every field.
|
|
|
5170
5177
|
Repeated visual region on a page? -> a COMPONENT with sectionType (a Section)
|
|
5171
5178
|
...and it comes in more than one look? -> siblings sharing that sectionType = VARIANTS
|
|
5172
5179
|
A stack of mixed, reorderable content? -> kind:'block' models + a \`modular\` field
|
|
5173
|
-
A row in a list (post, author, tier)? -> a collection (kind:'model')
|
|
5180
|
+
A row in a list (post, author, tier)? -> a collection (kind:'model'); rows one template
|
|
5181
|
+
route renders get urlPattern, e.g. '/blog/:slug'
|
|
5182
|
+
Copy for ONE page (home, about, legal)?-> page fields in sections (add_page_field /
|
|
5183
|
+
set_page_content) \u2014 never a one-entry collection
|
|
5174
5184
|
The one body of an article? -> type:'document' (exactly one, top level)
|
|
5175
5185
|
A fixed cluster of fields? -> group (never a 1-item repeater)
|
|
5176
5186
|
A repeating cluster? -> repeater
|
|
@@ -5890,8 +5900,8 @@ delivered value \u2014 they change editor chrome only.
|
|
|
5890
5900
|
BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
|
|
5891
5901
|
key you reference must exist.
|
|
5892
5902
|
- **\`ui.collapsed\`** \u2014 start a nesting field's panel closed. For long optional sections.
|
|
5893
|
-
- **\`ui.preview\`** \u2014 how ONE ITEM of a nesting field summarises itself when collapsed.
|
|
5894
|
-
|
|
5903
|
+
- **\`ui.preview\`** \u2014 how ONE ITEM of a nesting field summarises itself when collapsed. Each
|
|
5904
|
+
slot (\`title\`, \`subtitle\`, \`media\`) names a **CHILD FIELD KEY of that field** \u2014 not a value, not a dotted path.
|
|
5895
5905
|
Without it a repeater of ten testimonials reads "Item 1 \u2026 Item 10" and the author has to open
|
|
5896
5906
|
each one to find the one they meant. \`media\` may point at an \`image\`, \`file\` or url-ish
|
|
5897
5907
|
\`text\` child; the type is not constrained.
|
|
@@ -5903,19 +5913,25 @@ delivered value \u2014 they change editor chrome only.
|
|
|
5903
5913
|
in that order, a nav whose order is semantic, a timeline. Locking a list nobody should reorder
|
|
5904
5914
|
is the declaration there was previously no way to make; locking one out of tidiness takes a
|
|
5905
5915
|
capability away from the author, so do not set it "just in case".
|
|
5916
|
+
- **\`ui.control\`** \u2014 which control a LEAF edits with; omit it for the default. \`'segmented'\` on a
|
|
5917
|
+
\`select\` with a few short options (alignment, theme, size: one click instead of a dropdown);
|
|
5918
|
+
\`'slider'\` on a \`number\` with \`config.min\` < \`config.max\` (opacity, columns). Anywhere else it
|
|
5919
|
+
is refused with the rule named.
|
|
5906
5920
|
|
|
5907
|
-
**
|
|
5921
|
+
**Six refusals, all deliberate, all 400 with the fix in the message.**
|
|
5908
5922
|
1. An unknown key inside \`ui\` is REJECTED, not stripped. \`ui\` is a strict object precisely so
|
|
5909
5923
|
that a typo is a refusal you can read instead of a 200 with your declaration gone.
|
|
5910
5924
|
2. \`ui.preview\` on a LEAF field is refused \u2014 it summarises an item, and a leaf has no items.
|
|
5911
5925
|
Valid on \`group\`, \`repeater\`, an \`array\` with \`config.zones\`, or \`modular\`.
|
|
5912
|
-
3. \`preview.title\` / \`preview.media\` naming a key that is not a child of that field is refused,
|
|
5926
|
+
3. \`preview.title\` / \`preview.subtitle\` / \`preview.media\` naming a key that is not a child of that field is refused,
|
|
5913
5927
|
and the error lists the valid child keys.
|
|
5914
5928
|
4. \`ui.layout\` follows the same rule as \`preview\`: nesting fields only. A leaf has no items
|
|
5915
5929
|
to arrange.
|
|
5916
5930
|
5. \`ui.reorderable\` is STRICTER \u2014 it needs a LIST, not merely children. Valid on \`repeater\`,
|
|
5917
5931
|
an \`array\` with \`config.zones.repeatable\`, or \`modular\`. A \`group\` (and a non-repeatable
|
|
5918
5932
|
zone) holds exactly ONE item, so there is nothing there to drag and the write is refused.
|
|
5933
|
+
6. \`ui.control\` outside its fit is refused: \`segmented\` needs a \`select\`, \`slider\` a \`number\`
|
|
5934
|
+
whose \`config.min\` and \`config.max\` are both set with min < max.
|
|
5919
5935
|
|
|
5920
5936
|
**The same \`ui\` rides on COMPONENT PROPS.** \`create_component\` / \`update_component\` take it on
|
|
5921
5937
|
each entry of \`props\`, with the placement rules the prop vocabulary implies: \`preview\` and
|
|
@@ -6577,9 +6593,10 @@ Read bettercms://playbook/schema section 14 first \u2014 it has the exact prop s
|
|
|
6577
6593
|
**What you are setting** (all of these ride on any field, on create_content_model / create_page /
|
|
6578
6594
|
add_field / add_page_field; none of them affect delivery):
|
|
6579
6595
|
ui.collapsed start a nesting field's panel closed
|
|
6580
|
-
ui.preview {title,media} which CHILD FIELD KEY titles a collapsed row,
|
|
6596
|
+
ui.preview {title,subtitle,media} which CHILD FIELD KEY titles a collapsed row, its second line, its thumb
|
|
6581
6597
|
ui.layout 'list'|'grid'|'table' how its items are arranged; table is for repeatable row schemas; omit for list
|
|
6582
6598
|
ui.reorderable omit (= true) unless the ORDER IS THE MEANING; false LOCKS the list
|
|
6599
|
+
ui.control 'segmented'|'slider' a LEAF's control: segmented on a select, slider on a number with config.min < max
|
|
6583
6600
|
helpText one line under the field: what to WRITE here
|
|
6584
6601
|
showIf hide a field until another field has a value
|
|
6585
6602
|
|