@bettercms-ai/mcp 0.68.2 → 0.70.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 +165 -70
- package/dist/index.js.map +1 -1
- package/dist/skills/bettercms/SKILL.md +8 -4
- package/dist/skills/bettercms/references/accessibility.md +171 -0
- package/dist/skills/bettercms/references/astro.md +5 -1
- package/dist/skills/bettercms/references/bindings.md +47 -0
- package/dist/skills/bettercms/references/componentize.md +6 -4
- package/dist/skills/bettercms/references/components.md +56 -5
- package/dist/skills/bettercms/references/content-modeling.md +156 -24
- package/dist/skills/bettercms/references/detection.md +450 -0
- package/dist/skills/bettercms/references/editor-chrome.md +4 -0
- package/dist/skills/bettercms/references/forms-and-interactions.md +16 -4
- package/dist/skills/bettercms/references/frameworks.md +2 -2
- package/dist/skills/bettercms/references/media-and-brand.md +2 -1
- package/dist/skills/bettercms/references/next.md +2 -2
- package/dist/skills/bettercms/references/publishing.md +22 -4
- package/dist/skills/bettercms/references/seo.md +226 -26
- package/dist/skills/bettercms/references/site-blueprints.md +647 -2
- package/dist/skills/bettercms/references/site-review.md +2 -1
- package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -0
- package/dist/skills/bettercms/references/worked-examples.md +738 -0
- package/dist/skills/bettercms/references/write-limits.md +138 -4
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -27,7 +27,16 @@ var COMPONENT_PROP_TYPES = [
|
|
|
27
27
|
"group",
|
|
28
28
|
"table",
|
|
29
29
|
"slot",
|
|
30
|
-
"form"
|
|
30
|
+
"form",
|
|
31
|
+
// The seven below each store what the same-named content-model FIELD stores (`icon` and
|
|
32
|
+
// `multiselect` have no field twin; see the value notes on ComponentPropDef.type).
|
|
33
|
+
"link",
|
|
34
|
+
"button",
|
|
35
|
+
"date",
|
|
36
|
+
"color",
|
|
37
|
+
"file",
|
|
38
|
+
"icon",
|
|
39
|
+
"multiselect"
|
|
31
40
|
];
|
|
32
41
|
|
|
33
42
|
// ../types/src/layout-lucide-icons.ts
|
|
@@ -2510,7 +2519,7 @@ function allKeys(fields) {
|
|
|
2510
2519
|
})
|
|
2511
2520
|
]);
|
|
2512
2521
|
}
|
|
2513
|
-
var CLEARABLE = ["helpText", "showIf", "ui", "placement"];
|
|
2522
|
+
var CLEARABLE = ["helpText", "showIf", "ui", "placement", "readOnly", "lockReason"];
|
|
2514
2523
|
var SETTABLE = ["label", "required", ...CLEARABLE];
|
|
2515
2524
|
function mergeConfig(current, patch) {
|
|
2516
2525
|
const out = { ...current ?? {} };
|
|
@@ -2548,7 +2557,7 @@ function planFieldEdit(model, edit, callerIfMatch) {
|
|
|
2548
2557
|
}
|
|
2549
2558
|
const changes = SETTABLE.filter((k) => edit[k] !== void 0);
|
|
2550
2559
|
if (!retype && rename === void 0 && edit.config === void 0 && changes.length === 0) {
|
|
2551
|
-
return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, type or key." };
|
|
2560
|
+
return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, readOnly, lockReason, type or key." };
|
|
2552
2561
|
}
|
|
2553
2562
|
const echo = helpTextEcho(edit.helpText, { label: edit.label ?? current.label, defaultValue: current.defaultValue });
|
|
2554
2563
|
if (echo) return { ok: false, error: echo };
|
|
@@ -2601,6 +2610,7 @@ var LISTING_TAIL = /* @__PURE__ */ new Set([
|
|
|
2601
2610
|
"extract_component",
|
|
2602
2611
|
"list_extraction_candidates",
|
|
2603
2612
|
"create_deploy_upload",
|
|
2613
|
+
"import_redirects",
|
|
2604
2614
|
"set_authoring_preference",
|
|
2605
2615
|
"set_media_delivery",
|
|
2606
2616
|
"delete_form_submission",
|
|
@@ -2663,8 +2673,9 @@ function listsTool(name, sets) {
|
|
|
2663
2673
|
}
|
|
2664
2674
|
|
|
2665
2675
|
// src/tool-text.ts
|
|
2666
|
-
var ADD_FIELD_DESCRIPTION = "Append ONE field to a content model (`modelId`) or to a page's schema (`pageId`) \u2014 additive, never overwrites; a key already in use is refused. For a section/zone add ONE 'group' (fixed block) or 'repeater' (repeating list) carrying its child `fields`, not the zone's inner fields as separate top-level fields. Pass `after` to insert it after an existing top-level field instead of at the end; `ui`, `helpText`, `showIf`, `placement` and `fieldsetId` declare how it presents in the editor \u2014 read them back with get_content_model or get_page. On a page 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. To change a field that exists, use update_field.";
|
|
2676
|
+
var ADD_FIELD_DESCRIPTION = "Append ONE field to a content model (`modelId`) or to a page's schema (`pageId`) \u2014 additive, never overwrites; a key already in use is refused. For a section/zone add ONE 'group' (fixed block) or 'repeater' (repeating list) carrying its child `fields`, not the zone's inner fields as separate top-level fields. Pass `after` to insert it after an existing top-level field instead of at the end; `ui`, `helpText`, `showIf`, `placement` and `fieldsetId` declare how it presents in the editor \u2014 read them back with get_content_model or get_page. On a page 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. `multiselect` stores a string[] of `config.options` (`allowCustom` for tags); `icon` is a BetterCMS icon name or an uploaded icon. To change a field that exists, use update_field.";
|
|
2667
2677
|
var MOVE_STAGE_DESCRIPTION = "Move a content entry (`entryId`) or a page (`pageId`) to another editorial stage \u2014 this is how you take it OUT of review so it can be published. `workflowStage` is a stage key from get_workflow_board; pass `fromStage` to 409 if someone moved it first. Moving into the publish-gate stage is an approval and needs a publish-enabled connection. Publish afterwards with update_content_entry or update_page status:'published' \u2014 approving and publishing are deliberately two separate calls.";
|
|
2678
|
+
var WINDOW_NOTE = "{from?, until?}, ISO 8601 with offset: shown only between the two instants; null clears (skill publishing).";
|
|
2668
2679
|
var WEBHOOK_EVENTS = [
|
|
2669
2680
|
"content.publish",
|
|
2670
2681
|
"content.unpublish",
|
|
@@ -2679,7 +2690,7 @@ var NEW_TOOL_TEXT = {
|
|
|
2679
2690
|
discard_draft: "Throw away unpublished edits to an entry (`entryId`) or a page (`pageId`) \u2014 exactly one: the draft returns to exactly what is live and `pendingChanges` becomes false. A page resets its blockJson and its published Layout overrides. 409 no_published_version means it was never published (delete it instead). Pass ifMatch (optimisticVersion) to refuse if it changed since you read it.",
|
|
2680
2691
|
give_feedback: "Tell the BetterCMS team about a tool that failed, a capability you needed and did not have, or docs that misled you. Opens a ticket for the team; nothing in the project changes. Name the `tool`, and paste the request and response into `context`. Never include secrets or personal data.",
|
|
2681
2692
|
publish_entries: "Publish or unpublish many entries and pages in ONE call (up to 100 ids of each; pages publish only). Needs a publish-enabled connection, and each item still passes its own gates (editorial stage, required fields). Nothing stops the run: the result lists what changed (`published` or `unpublished`) and every refusal in `failed` with its reason. A publish answers 202 with the routes being rebuilt.",
|
|
2682
|
-
create_content_entries: "Create up to 200 DRAFT entries of one collection in ONE call, each {slug?, data} as create_content_entry takes it. Every row reports `created` (with id and slug) or `failed` (with the error), a failure does not stop the others, and `meta` counts
|
|
2693
|
+
create_content_entries: "Create up to 200 DRAFT entries of one collection in ONE call, each {slug?, data} as create_content_entry takes it. Every row reports `created` (with id and slug) or `failed` (with the error), a failure does not stop the others, and `meta` counts them. A row with `externalKey` is safe to send again: its entry is `updated` (draft only), or `unchanged` when the row is the same. A row setting a developer-locked field fails (FIELD_LOCKED). Publish afterwards with publish_entries.",
|
|
2683
2694
|
translate_entry: "Create an entry's `targetLocale` translation with AI (uses AI credits: 402/403 when out or not on the plan). Text and longtext fields are translated; rich text and documents are copied for you to translate; the rest is copied. The new entry is a DRAFT with `locale` set and `translationOf` = the source; `meta` lists translated and copied keys. `targetLocale` must be one of the project's locales; 409 locale_conflict means that translation exists, so edit it instead.",
|
|
2684
2695
|
list_webhooks: "List the project's outgoing webhooks: name, url, events, whether active, and delivery health (total, failed, last attempt). Signing secrets are never returned.",
|
|
2685
2696
|
create_webhook: "Create an outgoing webhook: BetterCMS POSTs each subscribed event to your https `url`, signed with HMAC-SHA256 in X-BetterCMS-Signature. The response carries the signing `secret` ONCE: store it now. `test: true` also sends a sample delivery. Payloads carry ids and metadata, never the content: fetch it from the API. A receiver that writes back on content.updated loops unless it skips its own writes.",
|
|
@@ -2689,12 +2700,23 @@ var NEW_TOOL_TEXT = {
|
|
|
2689
2700
|
search_docs: "Search the BetterCMS documentation. Returns the best pages (title, url, breadcrumbs, snippet). Use it before guessing how a feature works; `meta.unavailable` means docs search is down right now.",
|
|
2690
2701
|
generate_image: "Generate an image with AI and add it to the Media Library (5 AI credits; free with the workspace's own Together key). `width`/`height` are 256-1440 in multiples of 16 (default 1024x768). Returns the new asset (id, url, altText): use the url as an 'image' field value. Always set `altText`.",
|
|
2691
2702
|
refresh_search_index: "Tell search engines and site search about published changes; costs nothing. action 'indexnow' submits `urls` (this site's own, up to 1000) to IndexNow, or every change since the last submission when omitted. action 'reindex' rebuilds the project's site-search index. 503 means the index is unreachable: try again later.",
|
|
2703
|
+
import_redirects: "Bulk-create redirects for a migration, from `rows` or from `text` in `format` (our CSV export, a Netlify `_redirects` file, or a vercel.json). Call it with `dryRun: true` FIRST: nothing is written and each row comes back `would-create`, `existing` or `skipped` with a reason. Rows get create_redirect's rules. Max 5000.",
|
|
2692
2704
|
editor_setup: "Make the live build editable. A build imports only SEO/meta, media, brand and the models bcms-content.json declares; this derives the rest from the live release: pages, collections, forms, bindings, button and section components (when the authoring preference is components), Component Groups and the Content sidebar. Run it after an import or a rebuild, before the conversion brief. Idempotent: `ready` when it already ran for the live sha, else it starts (`running`; poll with `check: true` or get_deploy_status `editorSetup`). `state`: never or stale (run it), running, ready, failed. 409 = nothing is live yet: deploy first. Then get_next_steps.",
|
|
2693
2705
|
transform_image: "Edit an existing Media Library image with an instruction (e.g. 'remove the background', 'make it dusk'); the result is saved as a NEW asset and the original is kept (5 AI credits). Returns the new asset (id, url, altText)."
|
|
2694
2706
|
};
|
|
2695
2707
|
var DELETE_PAGE_RUNTIME_BUNDLE = " Or, with no pageId, `runtimeBundle`: 'report' lists the duplicate pages derived from a server app's own bundle (`app/` folders); 'apply' retires them and their empty folders.";
|
|
2708
|
+
var FIELD_LOCKED_NOTE = "A readOnly 'developer' field keeps its stored value: changing or omitting it without schema access is 403 FIELD_LOCKED.";
|
|
2696
2709
|
var PARAM_TEXT = {
|
|
2710
|
+
dryRun: "validates and reports what would be written; writes nothing",
|
|
2711
|
+
externalKey: "id in the source system; sent again, it updates that entry's draft or leaves it unchanged",
|
|
2712
|
+
upsertSource: "source URL, kept with the entry",
|
|
2713
|
+
upsertRunId: "your run's id, kept with the entry",
|
|
2714
|
+
externalKeyFilter: "exact externalKey an entry was created with",
|
|
2715
|
+
readOnly: "'developer' = the value and this lock change only with schema access (needs lockReason); true = an editor marker",
|
|
2716
|
+
lockReason: "which system owns the value (\u2264 200 chars)",
|
|
2717
|
+
lockClears: "as on add_field; null clears it",
|
|
2697
2718
|
helpText: "shown under the field: its purpose, where it shows, any constraint; never its label or value. Omitted: written for you.",
|
|
2719
|
+
fromBuiltin: "a builtin:* id from list_components: copies it into the project; fields you send win",
|
|
2698
2720
|
describes: "for editors: what it is and holds; omitted: written for you",
|
|
2699
2721
|
runtimeBundle: "instead of pageId: the server-app bundle cleanup",
|
|
2700
2722
|
pageRef: "page id or path ('/de/about'); a bare slug only when one page has it",
|
|
@@ -2719,14 +2741,14 @@ var PARAM_TEXT = {
|
|
|
2719
2741
|
confirmedInput: "true only after the user explicitly approved adding this custom input (`ui.input`); else setting or changing one is refused input_needs_confirmation. Removing one needs no approval"
|
|
2720
2742
|
};
|
|
2721
2743
|
var INPUT_CONFIRMED_HEADER = "x-bcms-confirmed-input";
|
|
2722
|
-
var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant?, size?, arrow?, icon?, newTab?} (arrow:false, icon:null turn one off): never a text field beside a link field.";
|
|
2723
|
-
var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, ui or
|
|
2744
|
+
var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant?, size?, arrow?, icon?, newTab?, rel?} (arrow:false, icon:null turn one off): never a text field beside a link field.";
|
|
2745
|
+
var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, ui, placement, readOnly or lockReason (null clears those). The rest of the schema is written back unchanged at the version read; a 409 means it changed since, so read again. `type` changes how every stored value is read and needs allowRetype: true. `key` renames it: stored values stay under the old key, so the route answers 409 with the entry count unless allowFieldRemoval: true discards them \u2014 migrate the values first. Ask the user before a retype or a rename. A page's label + link pair (primaryCtaText + primaryCtaHref) becomes one button with `mergeHref`.";
|
|
2724
2746
|
var GET_SEO_DESCRIPTION = "Get the connected project's site-wide SEO settings \u2014 every key update_seo writes: seoDefaults (metaTitle, metaDescription, ogImage, twitterHandle, og, twitter, collectionSchemaTypes), siteSchema (the site's JSON-LD), robotsConfig, sitemapConfig, rssConfig, indexnowConfig and searchConfig.";
|
|
2725
2747
|
var UPDATE_SEO_DESCRIPTION = "Set the connected project's site-wide SEO defaults and config. Each key you send is REPLACED whole (not deep-merged), so read get_seo and send the complete object. `seoDefaults` applies to every page that does not override it, and its `collectionSchemaTypes` maps a collection's model id to the schema.org type of its entries. `siteSchema` is the site's own JSON-LD (Organization/WebSite, emitted on the home page). `robotsConfig`, `sitemapConfig` and `rssConfig` shape robots.txt, sitemap.xml and the feed; `indexnowConfig` pings search engines on publish; `searchConfig` is site search. A key the plan does not include answers 403 FEATURE_LOCKED. Rebuilds the site.";
|
|
2726
2748
|
var SEO_KEY_TEXT = {
|
|
2727
2749
|
seoDefaults: "site-wide meta defaults every page inherits",
|
|
2728
2750
|
siteSchema: "the site's JSON-LD: an object or an array of objects",
|
|
2729
|
-
robotsConfig: "robots.txt: 'auto', or 'custom' with its text",
|
|
2751
|
+
robotsConfig: "robots.txt: 'auto' (`disallow` names AI crawlers to block), or 'custom' with its text",
|
|
2730
2752
|
sitemapConfig: "sitemap.xml: generated ('auto') or 'custom', defaults and extra URLs",
|
|
2731
2753
|
rssConfig: "the RSS feed on or off",
|
|
2732
2754
|
indexnowConfig: "IndexNow on publish, minus URL patterns to leave out",
|
|
@@ -2735,6 +2757,8 @@ var SEO_KEY_TEXT = {
|
|
|
2735
2757
|
var VARIANT_GROUP_NOTE = "When the component belongs to a family of looks, `variantGroup` names the family and lists its looks.";
|
|
2736
2758
|
var UNPROPPED_COPY_NOTE = "A `warnings` entry UNPROPPED_COPY names copy no prop targets, which nobody can edit: add a prop with a `target` for each leaf it lists.";
|
|
2737
2759
|
var UPDATE_PAGE_IF_MATCH_NOTE = "get_page's `optimisticVersion`: the write applies only at that version, and a 409 means the page changed since, so read it again. Send it whenever you pass blockJson, which REPLACES the whole composition.";
|
|
2760
|
+
var SLOT_MULTIPLE_NOTE = "'slot' with config.multiple holds a list [{componentId, overrides?}] (config.maxItems, default 12, at most 50): target.path 'props.children' on a section.";
|
|
2761
|
+
var LAYOUT_LOCK_NOTE = "A Global field with `overridable:false` + `lockReason` (add-field `field`, update-field `patch`) refuses page overrides (LAYOUT_FIELD_NOT_OVERRIDABLE); get_layout scope page lists stored ones as `shadowed`.";
|
|
2738
2762
|
|
|
2739
2763
|
// src/entry-query.ts
|
|
2740
2764
|
var text = (v) => Array.isArray(v) ? v.map(String).join(",") : String(v);
|
|
@@ -2753,7 +2777,7 @@ function whereToQuery(where) {
|
|
|
2753
2777
|
}
|
|
2754
2778
|
function entryListSearch(args) {
|
|
2755
2779
|
const q = new URLSearchParams();
|
|
2756
|
-
for (const key of ["modelId", "pageId", "status"]) if (args[key]) q.set(key, String(args[key]));
|
|
2780
|
+
for (const key of ["modelId", "pageId", "status", "externalKey"]) if (args[key]) q.set(key, String(args[key]));
|
|
2757
2781
|
for (const [k, v] of whereToQuery(args.where)) q.append(k, v);
|
|
2758
2782
|
for (const key of ["sort", "page", "limit"]) if (args[key] !== void 0 && args[key] !== null) q.set(key, String(args[key]));
|
|
2759
2783
|
if (args.detail !== true) q.set("select", "summary");
|
|
@@ -2896,7 +2920,9 @@ var fieldType = z.enum([
|
|
|
2896
2920
|
"color",
|
|
2897
2921
|
"json",
|
|
2898
2922
|
"file",
|
|
2899
|
-
"sections"
|
|
2923
|
+
"sections",
|
|
2924
|
+
"multiselect",
|
|
2925
|
+
"icon"
|
|
2900
2926
|
]);
|
|
2901
2927
|
var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
|
|
2902
2928
|
var fieldKey = z.string().regex(/^[a-zA-Z0-9_-]+$/, "letters, numbers, underscores, and hyphens only");
|
|
@@ -2915,8 +2941,8 @@ var uiObject = z.strictObject({
|
|
|
2915
2941
|
reorderable: z.boolean().optional().describe(
|
|
2916
2942
|
"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."
|
|
2917
2943
|
),
|
|
2918
|
-
control: z.enum(["segmented", "slider"]).optional().describe(
|
|
2919
|
-
"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."
|
|
2944
|
+
control: z.enum(["segmented", "slider", "rating"]).optional().describe(
|
|
2945
|
+
"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); 'rating' on a number with config.min, max and step. Anything else is refused with the rule named."
|
|
2920
2946
|
),
|
|
2921
2947
|
// A refine, not an enum: 236 names inlined into every schema that carries `ui` would be paid on
|
|
2922
2948
|
// every tools/list. The layoutIcon precedent; the server's z.enum is the guard.
|
|
@@ -2954,7 +2980,7 @@ var fieldShape = {
|
|
|
2954
2980
|
"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'})."
|
|
2955
2981
|
),
|
|
2956
2982
|
config: z.record(z.string(), z.unknown()).optional().describe(
|
|
2957
|
-
"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; richtext {inline:true, hostTag:'h2', accept:['h2','p','b','i','u','link'], features:['bold','italic','underline','link','headings'], allowMultipleParagraphs:false} \u2014 for a field bound to ONE element on the page: `hostTag` is that element's tag and `accept` must name it, so the editor's block-style control opens already showing 'Heading 2' instead of 'Paragraph'. Name exactly ONE heading level: publish refuses an <h3> value bound to an <h2> rather than nesting one heading inside the other. Keep 'p' in `accept` or the sanitiser strips a block demoted to Paragraph"
|
|
2983
|
+
"per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime, timezone}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model; richtext {inline:true, hostTag:'h2', accept:['h2','p','b','i','u','link'], features:['bold','italic','underline','link','headings'], allowMultipleParagraphs:false} \u2014 for a field bound to ONE element on the page: `hostTag` is that element's tag and `accept` must name it, so the editor's block-style control opens already showing 'Heading 2' instead of 'Paragraph'. Name exactly ONE heading level: publish refuses an <h3> value bound to an <h2> rather than nesting one heading inside the other. Keep 'p' in `accept` or the sanitiser strips a block demoted to Paragraph"
|
|
2958
2984
|
),
|
|
2959
2985
|
fields: z.array(z.lazy(() => fieldObject)).optional().describe(
|
|
2960
2986
|
"NESTED child fields \u2014 REQUIRED for type 'group' (one nested object, a Non-Repeatable Zone like blog_hero \u2192 heading, description, hero_image) and type 'repeater' (a repeatable array of such objects, a Repeatable Zone / section-list like testimonials \u2192 quote, author). A section with repeating items is a 'repeater'; a fixed grouped block is a 'group'. Recurse to any depth \u2014 do NOT flatten zones into separate top-level fields."
|
|
@@ -2970,8 +2996,8 @@ var fieldShape = {
|
|
|
2970
2996
|
rules: z.array(
|
|
2971
2997
|
z.object({
|
|
2972
2998
|
field: z.string().describe("the BARE KEY of another field on this same model"),
|
|
2973
|
-
op: z.enum(["eq", "neq", "in"]),
|
|
2974
|
-
value: z.union([z.string(), z.array(z.string())])
|
|
2999
|
+
op: z.enum(["eq", "neq", "in", "isSet"]),
|
|
3000
|
+
value: z.union([z.string(), z.array(z.string())]).optional().describe("omit for 'isSet' (the source holds any value); required for every other op")
|
|
2975
3001
|
})
|
|
2976
3002
|
)
|
|
2977
3003
|
}).optional().describe(
|
|
@@ -2991,7 +3017,9 @@ var fieldShape = {
|
|
|
2991
3017
|
searchable: z.boolean().optional().describe("omit (\u2261 true) to index this field for site search; false excludes it"),
|
|
2992
3018
|
unique: z.boolean().optional().describe(
|
|
2993
3019
|
"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`."
|
|
2994
|
-
)
|
|
3020
|
+
),
|
|
3021
|
+
readOnly: z.union([z.literal(true), z.literal("developer")]).optional().describe(PARAM_TEXT.readOnly),
|
|
3022
|
+
lockReason: z.string().max(200).optional().describe(PARAM_TEXT.lockReason)
|
|
2995
3023
|
};
|
|
2996
3024
|
var fieldObject = z.object(fieldShape);
|
|
2997
3025
|
var urlPatternArg = z.string().nullable().optional().describe(
|
|
@@ -3016,7 +3044,7 @@ var fieldsetsArg = z.array(
|
|
|
3016
3044
|
);
|
|
3017
3045
|
var dropProvenance = (sets) => sets?.map(({ uiOrigin: _origin, uiBefore: _before, ...set }) => set) ?? sets;
|
|
3018
3046
|
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.";
|
|
3019
|
-
var SCHEMA_ORG_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"];
|
|
3047
|
+
var SCHEMA_ORG_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness", "JobPosting", "Course", "Recipe", "HowTo", "Service", "Person", "VideoObject"];
|
|
3020
3048
|
var changefreq = z.enum(["auto", "always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]);
|
|
3021
3049
|
var jsonObject = z.record(z.string(), z.unknown());
|
|
3022
3050
|
var seoPatchShape = {
|
|
@@ -3030,7 +3058,7 @@ var seoPatchShape = {
|
|
|
3030
3058
|
collectionSchemaTypes: z.record(z.string().min(1).max(64), z.enum(SCHEMA_ORG_TYPES)).optional()
|
|
3031
3059
|
}).optional().describe(SEO_KEY_TEXT.seoDefaults),
|
|
3032
3060
|
siteSchema: z.union([jsonObject, z.array(jsonObject)]).optional().describe(SEO_KEY_TEXT.siteSchema),
|
|
3033
|
-
robotsConfig: z.object({ mode: z.enum(["auto", "custom"]), custom: z.string().max(1e4).optional() }).optional().describe(SEO_KEY_TEXT.robotsConfig),
|
|
3061
|
+
robotsConfig: z.object({ mode: z.enum(["auto", "custom"]), custom: z.string().max(1e4).optional(), disallow: z.array(z.enum(["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "anthropic-ai", "PerplexityBot", "Google-Extended", "CCBot", "Bytespider", "Applebot-Extended", "Amazonbot", "Meta-ExternalAgent"])).max(32).optional() }).optional().describe(SEO_KEY_TEXT.robotsConfig),
|
|
3034
3062
|
sitemapConfig: z.object({
|
|
3035
3063
|
enabled: z.boolean(),
|
|
3036
3064
|
mode: z.enum(["auto", "custom"]).optional(),
|
|
@@ -3079,6 +3107,8 @@ function proseType(f) {
|
|
|
3079
3107
|
}
|
|
3080
3108
|
var confirmedHeaders = (confirmed) => confirmed === true ? { [INPUT_CONFIRMED_HEADER]: "true" } : {};
|
|
3081
3109
|
var withHeaders = (headers) => Object.keys(headers).length > 0 ? { headers } : {};
|
|
3110
|
+
var DRY_RUN_SUMMARY = "Dry run: nothing was written.";
|
|
3111
|
+
var dryRunOf = async (client, method, path, body, headers = {}) => ok(DRY_RUN_SUMMARY, await client.fetchJSON(client.url(path), { method, body: JSON.stringify({ ...body, dryRun: true }), ...withHeaders(headers) }));
|
|
3082
3112
|
function toField(f) {
|
|
3083
3113
|
const base = {
|
|
3084
3114
|
key: f.key,
|
|
@@ -3093,7 +3123,10 @@ function toField(f) {
|
|
|
3093
3123
|
...f.defaultValue !== void 0 ? { defaultValue: f.defaultValue } : {},
|
|
3094
3124
|
...f.searchable !== void 0 ? { searchable: f.searchable } : {},
|
|
3095
3125
|
// BE-6: a leaf key like the four above, so it rides on `base` and survives every branch.
|
|
3096
|
-
...f.unique !== void 0 ? { unique: f.unique } : {}
|
|
3126
|
+
...f.unique !== void 0 ? { unique: f.unique } : {},
|
|
3127
|
+
// The developer lock: stripped here, an agent writing a model back would unlock every field.
|
|
3128
|
+
...f.readOnly !== void 0 ? { readOnly: f.readOnly } : {},
|
|
3129
|
+
...f.lockReason !== void 0 ? { lockReason: f.lockReason } : {}
|
|
3097
3130
|
};
|
|
3098
3131
|
if (f.type === "group") {
|
|
3099
3132
|
return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
|
|
@@ -3317,6 +3350,7 @@ function buildToolDefs(deps) {
|
|
|
3317
3350
|
}
|
|
3318
3351
|
};
|
|
3319
3352
|
}
|
|
3353
|
+
const windowArg = z.object({ from: z.string().optional(), until: z.string().optional() });
|
|
3320
3354
|
const blockObject = z.object({
|
|
3321
3355
|
type: z.enum([
|
|
3322
3356
|
"heading",
|
|
@@ -3338,11 +3372,12 @@ function buildToolDefs(deps) {
|
|
|
3338
3372
|
]).describe("block type; section/slider/tabs/columns nest child blocks"),
|
|
3339
3373
|
id: z.string().min(1).describe("stable unique block id"),
|
|
3340
3374
|
props: z.record(z.string(), z.unknown()).describe(
|
|
3341
|
-
"per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href, variant? primary|secondary|tertiary|brand|dark|light|outline|link, size? sm|md|lg, fullWidth?, arrow? boolean, icon? ui.icon name, newTab? boolean}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may hold ANY block; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug
|
|
3375
|
+
"per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href, variant? primary|secondary|tertiary|brand|dark|light|outline|link, size? sm|md|lg, fullWidth?, arrow? boolean, icon? ui.icon name, newTab? boolean, rel? nofollow|sponsored|ugc}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may hold ANY block; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?, source? {modelId}, filters? [{field, op, value}], sort?, pinnedIds?, excludeCurrent?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>; with `source`, that model's entries as cards on any page"
|
|
3342
3376
|
),
|
|
3343
3377
|
style: z.record(z.string(), z.unknown()).optional().describe(
|
|
3344
|
-
"design tokens: theme, bg (none|surface|muted|accent|dark|custom), bgCustom hex, paddingTop/paddingBottom/paddingSides px, contentWidth (narrow|default|wide|full), align, corner, shadow, borderTop/borderBottom, border (all four sides, in the text colour). A real marketing band is a `section` block carrying bg + padding + contentWidth."
|
|
3345
|
-
)
|
|
3378
|
+
"design tokens: theme, bg (none|surface|muted|accent|dark|custom), bgCustom hex, paddingTop/paddingBottom/paddingSides px, contentWidth (narrow|default|wide|full), align, corner, shadow, borderTop/borderBottom, border (all four sides, in the text colour), hideOn [mobile|tablet|desktop], never all three. A real marketing band is a `section` block carrying bg + padding + contentWidth."
|
|
3379
|
+
),
|
|
3380
|
+
window: windowArg.nullable().optional().describe(WINDOW_NOTE)
|
|
3346
3381
|
});
|
|
3347
3382
|
const createPageInput = z.object({
|
|
3348
3383
|
title: z.string().min(1).describe("display title of the page, e.g. 'Home'"),
|
|
@@ -3391,7 +3426,8 @@ function buildToolDefs(deps) {
|
|
|
3391
3426
|
fields: z.array(fieldObject).optional().describe(
|
|
3392
3427
|
"the model's typed schema fields. 'group'/'repeater' NEST their child fields (any depth) \u2014 don't flatten zones into top-level fields."
|
|
3393
3428
|
),
|
|
3394
|
-
fieldsets: fieldsetsArg
|
|
3429
|
+
fieldsets: fieldsetsArg,
|
|
3430
|
+
dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
|
|
3395
3431
|
});
|
|
3396
3432
|
const afterArg = z.string().min(1).optional().describe(
|
|
3397
3433
|
"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."
|
|
@@ -3399,6 +3435,7 @@ function buildToolDefs(deps) {
|
|
|
3399
3435
|
const addFieldInput = z.object({
|
|
3400
3436
|
modelId: z.string().min(1).optional().describe("the content model to extend (from list_content_models); or send pageId"),
|
|
3401
3437
|
pageId: z.string().min(1).optional().describe("instead of modelId: the page whose schema to extend (from list_pages / create_page)"),
|
|
3438
|
+
dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun),
|
|
3402
3439
|
...fieldShape,
|
|
3403
3440
|
after: afterArg,
|
|
3404
3441
|
confirmed: z.boolean().optional().describe(PARAM_TEXT.confirmedInput)
|
|
@@ -3413,13 +3450,20 @@ function buildToolDefs(deps) {
|
|
|
3413
3450
|
transcript: z.string().max(1e5).optional().describe("video/audio only: the spoken words (\u2264 100,000 chars), published wherever the file is used"),
|
|
3414
3451
|
folderId: z.string().optional().describe("target Media Library folder (defaults to project root)")
|
|
3415
3452
|
});
|
|
3453
|
+
const upsertShape = {
|
|
3454
|
+
externalKey: z.string().min(1).max(255).optional().describe(PARAM_TEXT.externalKey),
|
|
3455
|
+
source: z.string().max(2048).optional().describe(PARAM_TEXT.upsertSource),
|
|
3456
|
+
runId: z.string().max(100).optional().describe(PARAM_TEXT.upsertRunId)
|
|
3457
|
+
};
|
|
3416
3458
|
const createEntryInput = z.object({
|
|
3417
3459
|
contentModelId: z.string().min(1).describe("id of the model this entry belongs to"),
|
|
3418
3460
|
slug: slug.optional(),
|
|
3419
3461
|
status: z.enum(["draft", "published"]).optional().describe("defaults to draft"),
|
|
3420
3462
|
data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key"),
|
|
3421
3463
|
locale: z.string().nullable().optional().describe(PARAM_TEXT.locale),
|
|
3422
|
-
translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf)
|
|
3464
|
+
translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf),
|
|
3465
|
+
dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun),
|
|
3466
|
+
...upsertShape
|
|
3423
3467
|
});
|
|
3424
3468
|
const getPageInput = z.object({
|
|
3425
3469
|
pageId: z.string().min(1).describe(PARAM_TEXT.pageRef)
|
|
@@ -3439,6 +3483,7 @@ function buildToolDefs(deps) {
|
|
|
3439
3483
|
modelId: z.string().optional().describe("filter by content model id"),
|
|
3440
3484
|
pageId: z.string().optional().describe("filter by page id (a singleton page has one entry)"),
|
|
3441
3485
|
status: z.enum(["draft", "published"]).optional(),
|
|
3486
|
+
externalKey: z.string().optional().describe(PARAM_TEXT.externalKeyFilter),
|
|
3442
3487
|
where: z.record(z.string(), z.unknown()).optional().describe(PARAM_TEXT.where),
|
|
3443
3488
|
sort: z.string().max(100).optional().describe(PARAM_TEXT.sort),
|
|
3444
3489
|
page: z.number().int().min(1).optional().describe(PARAM_TEXT.page),
|
|
@@ -3456,7 +3501,7 @@ function buildToolDefs(deps) {
|
|
|
3456
3501
|
metaDescription: clearableText(500),
|
|
3457
3502
|
noindex: z.boolean().optional().describe("hide this entry from search engines and site search"),
|
|
3458
3503
|
...headPatchShape,
|
|
3459
|
-
schemaType: z.enum(
|
|
3504
|
+
schemaType: z.enum(SCHEMA_ORG_TYPES).nullable().optional(),
|
|
3460
3505
|
schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional()
|
|
3461
3506
|
});
|
|
3462
3507
|
const updateEntryInput = z.object({
|
|
@@ -3504,7 +3549,9 @@ function buildToolDefs(deps) {
|
|
|
3504
3549
|
"date",
|
|
3505
3550
|
"url",
|
|
3506
3551
|
"consent",
|
|
3507
|
-
"hidden"
|
|
3552
|
+
"hidden",
|
|
3553
|
+
"step"
|
|
3554
|
+
// `step` is a divider: the fields after it are one page
|
|
3508
3555
|
]),
|
|
3509
3556
|
placeholder: z.string().optional(),
|
|
3510
3557
|
helpText: z.string().optional().describe("hint shown under the control, muted"),
|
|
@@ -3564,8 +3611,8 @@ function buildToolDefs(deps) {
|
|
|
3564
3611
|
// enum omitted could not be echoed back, and an agent reading a component and writing it
|
|
3565
3612
|
// straight back DESTROYED every prop of that type on a 200.
|
|
3566
3613
|
type: z.enum(COMPONENT_PROP_TYPES),
|
|
3567
|
-
// 'slot' holds ONE nested component instance
|
|
3568
|
-
// fill it. Absent here until now, so a slot allowlist was unreachable from stdio even
|
|
3614
|
+
// 'slot' holds ONE nested component instance, or a list with config.multiple (the keys
|
|
3615
|
+
// pass through to the server); config.componentIds restricts what may fill it. Absent here until now, so a slot allowlist was unreachable from stdio even
|
|
3569
3616
|
// once the enum allowed the type.
|
|
3570
3617
|
config: z.object({
|
|
3571
3618
|
componentIds: z.array(z.string()).optional(),
|
|
@@ -3587,6 +3634,17 @@ function buildToolDefs(deps) {
|
|
|
3587
3634
|
required: z.boolean().optional().describe("mark the field required in the editor. An editor HINT: the API still accepts a placement that leaves it empty, and get_site_composition is what reports one."),
|
|
3588
3635
|
helpText: z.string().max(200).optional().describe("hint shown under the control, muted (\u2264 200 chars)"),
|
|
3589
3636
|
placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)"),
|
|
3637
|
+
// Stored markers on a prop: nothing refuses a prop value for them. Declared so an echo keeps them.
|
|
3638
|
+
readOnly: z.union([z.literal(true), z.literal("developer")]).optional(),
|
|
3639
|
+
lockReason: z.string().max(200).optional(),
|
|
3640
|
+
showIf: z.object({
|
|
3641
|
+
match: z.enum(["all", "any"]),
|
|
3642
|
+
rules: z.array(z.object({
|
|
3643
|
+
field: z.string().min(1),
|
|
3644
|
+
op: z.enum(["eq", "neq", "in", "isSet"]),
|
|
3645
|
+
value: z.union([z.string(), z.array(z.string())]).optional()
|
|
3646
|
+
})).min(1).max(20)
|
|
3647
|
+
}).optional().describe("show this prop only while SIBLING props (by key) match: {match:'all'|'any', rules:[{field, op:'eq'|'neq'|'in'|'isSet', value?}]}. 'isSet' takes no value. Editor-only."),
|
|
3590
3648
|
// ⚠ A STRIP SITE, exactly like OutField above: this is a `z.object`, so a key the model
|
|
3591
3649
|
// sends and this shape does not declare is dropped BEFORE the request leaves — a 200 with
|
|
3592
3650
|
// the declaration gone. `ui` rode through the backend's looseObject `config` and died here.
|
|
@@ -3613,9 +3671,9 @@ function buildToolDefs(deps) {
|
|
|
3613
3671
|
z.object({ type: z.literal("swap-component-variant"), ...sectionTarget, itemId: z.string().min(1), componentId: z.string().min(1) }),
|
|
3614
3672
|
z.object({ type: z.literal("set-page-state"), ...sectionTarget, state: z.enum(["inherit", "override-content", "customize-structure", "disable", "reset"]) }),
|
|
3615
3673
|
z.object({ type: z.literal("set-section-state"), ...sectionTarget, state: z.enum(["inherit", "override-content", "customize-structure", "disable", "reset"]) }),
|
|
3616
|
-
z.object({ type: z.literal("add-section"), name: z.string().min(1), icon: layoutIcon, zone: z.enum(["before_page", "after_page"]) }),
|
|
3674
|
+
z.object({ type: z.literal("add-section"), name: z.string().min(1), icon: layoutIcon, zone: z.enum(["before_page", "after_page"]), window: windowArg.optional() }),
|
|
3617
3675
|
z.object({ type: z.literal("restore-section"), section: z.object({ id: z.string().min(1) }).passthrough(), data: z.object({ fields: z.record(z.string(), z.unknown()) }), placement: z.object({ zone: z.enum(["above-page-content", "below-page-content"]), localOrderKey: z.string().min(1), afterSectionId: z.string().optional(), beforeSectionId: z.string().optional() }).optional() }),
|
|
3618
|
-
z.object({ type: z.literal("update-section"), ...sectionTarget, name: z.string().min(1).optional(), icon: layoutIcon.optional(), slug: z.string().min(1).optional(), zone: z.enum(["above-page-content", "below-page-content"]).optional(), orderKey: z.string().min(1).optional(), description: z.string().optional() }),
|
|
3676
|
+
z.object({ type: z.literal("update-section"), ...sectionTarget, name: z.string().min(1).optional(), icon: layoutIcon.optional(), slug: z.string().min(1).optional(), zone: z.enum(["above-page-content", "below-page-content"]).optional(), orderKey: z.string().min(1).optional(), description: z.string().optional(), window: windowArg.nullable().optional().describe("as a block's `window`") }),
|
|
3619
3677
|
z.object({ type: z.literal("remove-section"), ...sectionTarget }),
|
|
3620
3678
|
z.object({ type: z.literal("move-section"), ...sectionTarget, afterId: z.string().nullable().optional(), beforeId: z.string().nullable().optional() }),
|
|
3621
3679
|
z.object({ type: z.literal("add-field"), ...sectionTarget, parentFieldId: z.string().optional(), fieldType: z.enum(["text", "longtext", "richtext", "number", "toggle", "date", "image", "file", "link", "email", "phone", "select", "color", "json", "reference", "multi-reference", "group", "repeater"]).optional(), field: z.object({ id: z.string(), slug: z.string(), label: z.string(), type: z.string() }).passthrough().optional() }),
|
|
@@ -3664,12 +3722,13 @@ function buildToolDefs(deps) {
|
|
|
3664
3722
|
group: z.string().min(1).max(100).optional().describe("the folder editors see in the dashboard's Components tab, by NAME (e.g. 'Sections', 'Forms'); CREATED when missing. Separate from `category`, the picker tab."),
|
|
3665
3723
|
sectionType: sectionType.optional(),
|
|
3666
3724
|
variantOf: z.string().min(1).max(255).optional().describe("id/slug of the component this is a VARIANT of: joins or starts its family (422 if its shared inputs are missing)"),
|
|
3725
|
+
from: z.string().min(1).max(255).optional().describe(PARAM_TEXT.fromBuiltin),
|
|
3667
3726
|
projectId: z.string().nullable().optional().describe("owning project id; null creates a workspace-wide global component"),
|
|
3668
3727
|
allowedOn: allowedOn.optional(),
|
|
3669
3728
|
description: z.string().optional().describe(PARAM_TEXT.describes),
|
|
3670
3729
|
blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
|
|
3671
3730
|
props: z.array(componentPropObject).default([]).describe(
|
|
3672
|
-
"overridable fields. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a prop targets are click-to-edit. Each one may carry `ui` to say how its control presents in the inspector \u2014 a collapsed group, what titles and thumbnails a table's rows, whether that table's rows can be dragged at all."
|
|
3731
|
+
"overridable fields. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a prop targets are click-to-edit. Each one may carry `ui` to say how its control presents in the inspector \u2014 a collapsed group, what titles and thumbnails a table's rows, whether that table's rows can be dragged at all. 'button' is ONE unit {label, href, variant?, size?, arrow?, icon?, newTab?} on a button block's 'props', never a text prop plus a url prop. 'link' an href, 'date' YYYY-MM-DD, 'color' a hex, 'file' {url, name?}, 'icon' an icon name or an image, 'multiselect' a string[] from config.options (required). " + SLOT_MULTIPLE_NOTE
|
|
3673
3732
|
)
|
|
3674
3733
|
});
|
|
3675
3734
|
const updateComponentInput = z.object({
|
|
@@ -3729,7 +3788,8 @@ function buildToolDefs(deps) {
|
|
|
3729
3788
|
slug: z.string().min(1).optional().describe("a page slug, when one page has it; or pass `pageId`"),
|
|
3730
3789
|
blockJson: z.array(blockObject).describe("REPLACES this page's whole block composition")
|
|
3731
3790
|
})
|
|
3732
|
-
).min(1).max(50)
|
|
3791
|
+
).min(1).max(50),
|
|
3792
|
+
dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
|
|
3733
3793
|
});
|
|
3734
3794
|
const listComponentsInput = z.object({
|
|
3735
3795
|
detail: z.boolean().optional().describe("true = every component's full blockJson and props. Costly and rarely what you want \u2014 read one definition with get_component instead.")
|
|
@@ -3795,16 +3855,17 @@ function buildToolDefs(deps) {
|
|
|
3795
3855
|
def(
|
|
3796
3856
|
"update_media",
|
|
3797
3857
|
"Update a media asset",
|
|
3798
|
-
"Describe an existing media asset: set its alt text, caption, transcript or focal point (focalX/focalY, the spot crops keep in frame; delivered images carry it with width, height and thumbHash). 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.",
|
|
3858
|
+
"Describe an existing media asset: set its alt text, caption, transcript, `decorative` (no alt text needed) or focal point (focalX/focalY, the spot crops keep in frame; delivered images carry it with width, height and thumbHash). 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.",
|
|
3799
3859
|
z.object({
|
|
3800
3860
|
assetId: z.string().min(1).describe("media asset id (from list_media / upload_asset)"),
|
|
3801
3861
|
altText: z.string().max(500).nullable().optional().describe("alt text; null clears"),
|
|
3802
3862
|
caption: z.string().max(1e3).nullable().optional().describe("caption; null clears"),
|
|
3803
3863
|
transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only."),
|
|
3864
|
+
decorative: z.boolean().optional().describe("needs no alt text"),
|
|
3804
3865
|
focalX: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalX),
|
|
3805
3866
|
focalY: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalY)
|
|
3806
3867
|
}).shape,
|
|
3807
|
-
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, focalX: a.focalX, focalY: a.focalY }))
|
|
3868
|
+
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, decorative: a.decorative, focalX: a.focalX, focalY: a.focalY }))
|
|
3808
3869
|
),
|
|
3809
3870
|
def(
|
|
3810
3871
|
"list_media",
|
|
@@ -3994,15 +4055,27 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3994
4055
|
"update_redirect",
|
|
3995
4056
|
"Update a redirect",
|
|
3996
4057
|
"Update an existing redirect in place \u2014 change where it points, its HTTP status, or disable it without deleting. Prefer this over delete+create: it keeps the redirect's id and preserves the chain-collapse rewrites of other rules pointing at it. Only provided fields change. Loops 422, duplicate sources 409.",
|
|
3997
|
-
z.object({ redirectId: z.string().min(1).describe("redirect id (from list_redirects)"), sourcePath: z.string().min(1).optional(), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308"]).optional(), isActive: z.boolean().optional().describe("set false to disable without deleting") }).shape,
|
|
4058
|
+
z.object({ redirectId: z.string().min(1).describe("redirect id (from list_redirects)"), sourcePath: z.string().min(1).optional(), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308", "410"]).optional().describe("HTTP status; 410 = Gone"), isActive: z.boolean().optional().describe("set false to disable without deleting") }).shape,
|
|
3998
4059
|
async (c, a) => ok("Updated redirect.", await data(c, "PATCH", `/management/redirects/${s(a.redirectId)}`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType, isActive: a.isActive }))
|
|
3999
4060
|
),
|
|
4000
4061
|
def(
|
|
4001
4062
|
"create_redirect",
|
|
4002
4063
|
"Create a redirect",
|
|
4003
4064
|
"Create a URL redirect \u2014 e.g. a 301 after renaming a page's slug. `sourcePath` is the path to redirect FROM ('/old-page'), `destination` the path/url TO ('/new-page'). Chains collapse; loops/dupes rejected. Defaults to 301.",
|
|
4004
|
-
z.object({ sourcePath: z.string().min(1), destination: z.string().min(1), redirectType: z.enum(["301", "302", "307", "308"]).optional() }).shape,
|
|
4005
|
-
async (c, a) => ok("Created redirect.", await data(c, "POST", `/management/redirects`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType }))
|
|
4065
|
+
z.object({ sourcePath: z.string().min(1), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308", "410"]).optional().describe("default '301'; 410 = Gone, omit destination"), dryRun: z.boolean().optional().describe("true = return the verdict, write nothing") }).shape,
|
|
4066
|
+
async (c, a) => ok(a.dryRun === true ? "Dry run: nothing written." : "Created redirect.", await data(c, "POST", `/management/redirects`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType, dryRun: a.dryRun }))
|
|
4067
|
+
),
|
|
4068
|
+
def(
|
|
4069
|
+
"import_redirects",
|
|
4070
|
+
"Import redirects",
|
|
4071
|
+
NEW_TOOL_TEXT.import_redirects,
|
|
4072
|
+
z.object({
|
|
4073
|
+
rows: z.array(z.record(z.string(), z.unknown())).optional().describe("[{ sourcePath, destination?, redirectType? }]"),
|
|
4074
|
+
text: z.string().optional().describe("the file's contents, instead of rows"),
|
|
4075
|
+
format: z.enum(["csv", "_redirects", "vercel"]).optional().describe("of `text` (default 'csv')"),
|
|
4076
|
+
dryRun: z.boolean().optional().describe("default false")
|
|
4077
|
+
}).shape,
|
|
4078
|
+
async (c, a) => ok(a.dryRun === true ? "Dry run: nothing written." : "Imported redirects.", await data(c, "POST", `/management/redirects/import`, { rows: a.rows, text: a.text, format: a.format, dryRun: a.dryRun }))
|
|
4006
4079
|
),
|
|
4007
4080
|
def(
|
|
4008
4081
|
"delete_redirect",
|
|
@@ -4225,7 +4298,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
4225
4298
|
def(
|
|
4226
4299
|
"submit_conversion_receipt",
|
|
4227
4300
|
"Record what the conversion codemod could and could not do",
|
|
4228
|
-
"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`
|
|
4301
|
+
"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` and `pendingTotal`, and echoes the first 40 pending rows with their `fix` (`action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; the row shape: skill bindings; a 404 `unknown-brief` means that digest was never issued here). 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. \u{1F534} The same run owes two more things (playbook \xA713 steps 2b and 3b): the `--forms` receipt (a SECOND call, same `briefDigest`) and the `styleEdits` of `convert --verify-styles` inside this receipt; any changed class or style is refused with 409 `style-edits`. Requires artifact:write.",
|
|
4229
4302
|
z.object({
|
|
4230
4303
|
briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
|
|
4231
4304
|
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`.")
|
|
@@ -4336,7 +4409,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
4336
4409
|
"update_page",
|
|
4337
4410
|
"Edit a page",
|
|
4338
4411
|
"Edit a page: title, slug, SEO metaTitle/metaDescription, structured data (`schemaType`, `schema`), publish status (draft|published), and `blockJson` (its block composition \u2014 passing it REPLACES the whole array, so read get_page first and send its `optimisticVersion` as `ifMatch`). A `blockJson` that swaps a component block to another component comes back with `warnings` (COMPONENT_SWAP_DROPS_OVERRIDES) naming the overrides the new component does not render; they stay on the block, so swapping back restores them. It does NOT change the field SCHEMA \u2014 use add_field (pageId) / set_page_content for that. Renaming the slug keeps content intact. Publishing copies the draft blocks live in the same call \u2014 and on a singleton it publishes the page's content entry too; if the entry's workflow stage refuses, the page still goes live and `warnings` carries ENTRY_STILL_DRAFT with the entry id and the reason. STRUCTURED DATA is native SEO, never a field: leave it out and the page gets Automatic JSON-LD from its content (WebSite on the home page, Blog/CollectionPage on a collection's list page, WebPage otherwise); `schemaType` picks an explicit schema.org type whose properties are filled from the content; `schema` is pasted JSON-LD and wins over both (null clears it). " + PAGE_HEAD_NOTE + " " + SECTION_DOCTRINE,
|
|
4339
|
-
z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1).describe(PARAM_TEXT.pageRef), 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(), schemaType: z.enum(["auto",
|
|
4412
|
+
z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1).describe(PARAM_TEXT.pageRef), 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(), schemaType: z.enum(["auto", ...SCHEMA_ORG_TYPES]).optional().describe("structured data type; 'auto' (the default) derives it from the content"), schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional().describe("custom JSON-LD; wins over schemaType; null clears it"), status: z.enum(["draft", "published"]).optional(), scheduledAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageScheduledAt), unpublishAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageUnpublishAt), locale: z.string().nullable().optional().describe(PARAM_TEXT.locale), translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf), ifMatch: z.number().int().min(0).optional().describe(UPDATE_PAGE_IF_MATCH_NOTE), dynamic: z.object({ previewPath: z.string().nullable().optional().describe(PARAM_TEXT.previewPath), modelId: z.string().min(1).optional().describe(PARAM_TEXT.pageModelId) }).optional().describe(PARAM_TEXT.pageDynamic) }).shape,
|
|
4340
4413
|
async (c, a) => {
|
|
4341
4414
|
const res = await c.fetchJSON(
|
|
4342
4415
|
c.url(`/management/pages/${s(a.pageId)}/meta`),
|
|
@@ -4365,7 +4438,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4365
4438
|
def(
|
|
4366
4439
|
"deploy_project",
|
|
4367
4440
|
"Deploy new source/build",
|
|
4368
|
-
"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`, or the `uploadKey` of an archive you PUT through create_deploy_upload (no size ceiling: use it for anything large, or after a 413/502). 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. Returns `canvas.lane` (
|
|
4441
|
+
"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`, or the `uploadKey` of an archive you PUT through create_deploy_upload (no size ceiling: use it for anything large, or after a 413/502). 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. Returns `canvas.lane` (skill bindings) 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.",
|
|
4369
4442
|
z.object({
|
|
4370
4443
|
data: z.string().min(1).optional().describe("base64 .tgz/.zip of the project; or send uploadKey"),
|
|
4371
4444
|
uploadKey: z.string().min(1).optional().describe("instead of data: the uploadKey from create_deploy_upload, after PUTting the archive to its uploadUrl"),
|
|
@@ -4494,6 +4567,8 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4494
4567
|
showIf: z.record(z.string(), z.unknown()).nullable().optional().describe("same shape as on add_field; null clears it"),
|
|
4495
4568
|
ui: z.record(z.string(), z.unknown()).nullable().optional().describe("same vocabulary as on add_field; null clears it"),
|
|
4496
4569
|
placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).nullable().optional(),
|
|
4570
|
+
readOnly: z.union([z.literal(true), z.literal("developer")]).nullable().optional().describe(PARAM_TEXT.lockClears),
|
|
4571
|
+
lockReason: z.string().max(200).nullable().optional().describe(PARAM_TEXT.lockClears),
|
|
4497
4572
|
type: fieldType.optional().describe("a new type; needs allowRetype"),
|
|
4498
4573
|
key: fieldKey.optional().describe("a new key (flat namespace, unique in the schema)"),
|
|
4499
4574
|
allowRetype: z.boolean().optional().describe("confirm a type change, once the user has agreed"),
|
|
@@ -4604,10 +4679,11 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4604
4679
|
NEW_TOOL_TEXT.create_content_entries,
|
|
4605
4680
|
z.object({
|
|
4606
4681
|
contentModelId: z.string().min(1).describe("the collection every entry belongs to (from list_content_models)"),
|
|
4607
|
-
entries: z.array(z.object({ slug: z.string().optional(), data: z.record(z.string(), z.unknown()) })).min(1).max(200).describe("1-200 entries, each {slug?, data}")
|
|
4682
|
+
entries: z.array(z.object({ slug: z.string().optional(), data: z.record(z.string(), z.unknown()), ...upsertShape })).min(1).max(200).describe("1-200 entries, each {slug?, data}"),
|
|
4683
|
+
dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
|
|
4608
4684
|
}).shape,
|
|
4609
4685
|
// The whole body: the per-row receipt is `data`, the counts ride in `meta`.
|
|
4610
|
-
async (c, a) => ok("Batch receipt.", await c.fetchJSON(c.url(`/management/content/entries/batch`), { method: "POST", body: JSON.stringify({ contentModelId: a.contentModelId, entries: a.entries }) }))
|
|
4686
|
+
async (c, a) => ok(a.dryRun ? DRY_RUN_SUMMARY : "Batch receipt.", await c.fetchJSON(c.url(`/management/content/entries/batch`), { method: "POST", body: JSON.stringify({ contentModelId: a.contentModelId, entries: a.entries, dryRun: a.dryRun }) }))
|
|
4611
4687
|
),
|
|
4612
4688
|
def(
|
|
4613
4689
|
"translate_entry",
|
|
@@ -4726,14 +4802,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4726
4802
|
def(
|
|
4727
4803
|
"get_binding_report",
|
|
4728
4804
|
"Check what on the live site is editable",
|
|
4729
|
-
"The receipt for 'is this site actually EDITABLE?'. Each release scans the built HTML for the element rendering each CMS field value; per slot this returns `mode` ('text-match' = guessed from rendered text, 'declared' = template-declared), what was bound, and `unmatched`: each path with its reason
|
|
4730
|
-
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.
|
|
4805
|
+
"The receipt for 'is this site actually EDITABLE?'. Each release scans the built HTML for the element rendering each CMS field value; per slot this returns `mode` ('text-match' = guessed from rendered text, 'declared' = template-declared), what was bound, and `unmatched`: each path with its reason (reasons: skill bindings). DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: a HEADLESS site never gets one (`never-generated`); `layoutRoutes` and `coverage.error`: skill bindings. `unaddressable` is the visible live text no field owns, by route (null = unmeasured: diff visible text against entry values); over `count / visible > 0.02`, fix it in the SOURCE and re-read (playbook \xA711). `canvas.lane`: `none` \u2192 get_next_steps.",
|
|
4806
|
+
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. Other per-slot fields: skill bindings.") }).shape,
|
|
4731
4807
|
async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
|
|
4732
4808
|
),
|
|
4733
4809
|
def(
|
|
4734
4810
|
"get_conversion_brief",
|
|
4735
4811
|
"Get the brief for making this site's bindings durable",
|
|
4736
|
-
"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_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.
|
|
4812
|
+
"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_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. Recipe: playbook \xA713; get_binding_report is the finish line. 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`: wiring them and publishing the forms it names, skill bindings.",
|
|
4737
4813
|
z.object({
|
|
4738
4814
|
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."),
|
|
4739
4815
|
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.")
|
|
@@ -4750,14 +4826,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4750
4826
|
def(
|
|
4751
4827
|
"get_site_composition",
|
|
4752
4828
|
"Check every page is assembled from registered components",
|
|
4753
|
-
"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
|
|
4829
|
+
"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`, a block census, `nonComponentBlocks` and every `component` placement with its `status` (a draft renders as an EMPTY STRING on the live site), `inPicker` and `emptyRequiredProps`. Site-wide: counts, `awaitingApproval` (a HUMAN approves in the dashboard; no tool here can), `notInPicker`, `unused` and `layout.placements`. Each field: skill components. `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.",
|
|
4754
4830
|
z.object({}).shape,
|
|
4755
4831
|
async (c) => ok("Site composition.", await data(c, "GET", `/management/projects/current/composition`))
|
|
4756
4832
|
),
|
|
4757
4833
|
def(
|
|
4758
4834
|
"get_componentize_plan",
|
|
4759
4835
|
"Get the plan for turning this site's sections into components",
|
|
4760
|
-
"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).
|
|
4836
|
+
"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). `pending` says why a group is not offered (its codes: see `summary`). Per-page components, collection templates and chrome (never a section): skill componentize. 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.",
|
|
4761
4837
|
z.object({
|
|
4762
4838
|
summary: z.boolean().optional().describe("true = no values: each section's groupKey, family, pending and component slug. Read this first. `pending` codes: `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)."),
|
|
4763
4839
|
paged: z.boolean().optional().describe("true = 10 pages at a time; the response carries `page`, `pageCount` and a `cursor` for the next."),
|
|
@@ -4768,7 +4844,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4768
4844
|
def(
|
|
4769
4845
|
"componentize_sections",
|
|
4770
4846
|
"Turn this site's derived sections into components",
|
|
4771
|
-
"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
|
|
4847
|
+
"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 (`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` first (skill components). Re-running is safe: a placed group returns as ALREADY_COMPONENTIZED. 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 call it again with `codemod: true` (or run `npx @bettercms-ai/convert --componentize` in the repo) so its templates render these sections from `pages[].blocks`. When the user says a proposed group is NOT a section, pass `decline: true` with those `sections` (and `pageIds`): nothing is componentized and the plan stops proposing them (skill componentize).",
|
|
4772
4848
|
z.object({
|
|
4773
4849
|
digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
|
|
4774
4850
|
pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
|
|
@@ -4800,7 +4876,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4800
4876
|
def(
|
|
4801
4877
|
"list_seo_issues",
|
|
4802
4878
|
"List SEO issues",
|
|
4803
|
-
"Scan every published page for SEO problems (missing or over-long titles and descriptions, missing OG image, noindex,
|
|
4879
|
+
"Scan every published page for SEO problems (missing or over-long titles and descriptions, missing OG image, noindex, invalid pasted schema) and return them with severities. Also reports accessibility issues as `a11y.*` codes with block ids. Computed live \u2014 nothing is stored.",
|
|
4804
4880
|
z.object({}).shape,
|
|
4805
4881
|
async (c) => ok("SEO issues.", await data(c, "GET", `/management/insights/seo-issues`))
|
|
4806
4882
|
),
|
|
@@ -4997,7 +5073,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4997
5073
|
def(
|
|
4998
5074
|
"get_deploy_status",
|
|
4999
5075
|
"Get deploy/build status",
|
|
5000
|
-
"Get the connected project's deploy/build status: state (idle|queued|building|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `editorSetup`: whether editing is set up for the live build (never or stale = call editor_setup). Poll this after deploy_project until state is idle with your sha live. `release`
|
|
5076
|
+
"Get the connected project's deploy/build status: state (idle|queued|building|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `editorSetup`: whether editing is set up for the live build (never or stale = call editor_setup). Poll this after deploy_project until state is idle with your sha live. `release` and `held`: skill publishing. `runtime` ({state: ok|crash-loop, restarts, oomKilled}) appears for a server app only: crash-loop means its container keeps restarting (often out of memory) though the build went live fine. Also returns `canvas.lane` (skill bindings) and `editorUrl`, the visual editor to hand the user.",
|
|
5001
5077
|
z.object({}).shape,
|
|
5002
5078
|
// Flat body, no `data` envelope: `data()` read it as undefined.
|
|
5003
5079
|
async (c) => ok("Deploy status.", await c.fetchJSON(c.url(`/management/projects/deploy-status`), { method: "GET" }))
|
|
@@ -5142,7 +5218,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5142
5218
|
async (args) => withClient(async (client) => {
|
|
5143
5219
|
const modelDupes = duplicateKeys(flatFieldKeys(args.fields));
|
|
5144
5220
|
if (modelDupes.length > 0) return fail(duplicateKeyFailure(modelDupes));
|
|
5145
|
-
const
|
|
5221
|
+
const input = {
|
|
5146
5222
|
name: args.name,
|
|
5147
5223
|
slug: args.slug,
|
|
5148
5224
|
...args.description !== void 0 ? { description: args.description } : {},
|
|
@@ -5150,7 +5226,9 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5150
5226
|
...args.kind !== void 0 ? { kind: args.kind } : {},
|
|
5151
5227
|
...args.fieldsets !== void 0 ? { fieldsets: dropProvenance(args.fieldsets) } : {},
|
|
5152
5228
|
fields: toFields(args.fields)
|
|
5153
|
-
}
|
|
5229
|
+
};
|
|
5230
|
+
if (args.dryRun) return dryRunOf(client, "POST", `/management/content/models`, input);
|
|
5231
|
+
const model = await client.createModel(input);
|
|
5154
5232
|
return ok(
|
|
5155
5233
|
`Created content model '${model.name}' (${model.fields.length} field(s)).`,
|
|
5156
5234
|
model
|
|
@@ -5168,6 +5246,15 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5168
5246
|
handler: guard(
|
|
5169
5247
|
async (args) => withClient(async (client) => {
|
|
5170
5248
|
if (!args.modelId === !args.pageId) return fail("Pass exactly one of modelId (a content model) or pageId (a page).");
|
|
5249
|
+
if (args.dryRun) {
|
|
5250
|
+
return dryRunOf(
|
|
5251
|
+
client,
|
|
5252
|
+
args.pageId ? "PATCH" : "POST",
|
|
5253
|
+
args.pageId ? `/management/pages/${args.pageId}` : `/management/content/models/${args.modelId}/fields`,
|
|
5254
|
+
{ addFields: [toField(args)], ...args.after !== void 0 ? { after: args.after } : {} },
|
|
5255
|
+
confirmedHeaders(args.confirmed)
|
|
5256
|
+
);
|
|
5257
|
+
}
|
|
5171
5258
|
if (args.pageId) {
|
|
5172
5259
|
const input2 = {
|
|
5173
5260
|
// Cast: the SDK types `options` as string[], narrower than the API, which also takes
|
|
@@ -5224,19 +5311,23 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5224
5311
|
},
|
|
5225
5312
|
handler: guard(
|
|
5226
5313
|
async (args) => withClient(async (client) => {
|
|
5227
|
-
const
|
|
5314
|
+
const input = {
|
|
5228
5315
|
contentModelId: args.contentModelId,
|
|
5229
5316
|
...args.slug !== void 0 ? { slug: args.slug } : {},
|
|
5230
5317
|
...args.data !== void 0 ? { data: args.data } : {},
|
|
5231
5318
|
// The SDK's input type predates localization; its body is the input as given.
|
|
5232
5319
|
...args.locale !== void 0 ? { locale: args.locale } : {},
|
|
5233
|
-
...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {}
|
|
5234
|
-
|
|
5320
|
+
...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {},
|
|
5321
|
+
...args.externalKey !== void 0 ? { externalKey: args.externalKey, source: args.source, runId: args.runId } : {}
|
|
5322
|
+
};
|
|
5323
|
+
if (args.dryRun) return dryRunOf(client, "POST", `/management/content/entries`, input);
|
|
5324
|
+
const created = await client.createEntry(input);
|
|
5235
5325
|
const published = args.status !== void 0 && args.status !== "draft" ? await client.updateEntry(created.id, { status: args.status }) : created;
|
|
5236
5326
|
const { warnings, ...entry } = published;
|
|
5237
5327
|
const notes = [.../* @__PURE__ */ new Set([...created.warnings ?? [], ...warnings ?? []])];
|
|
5238
5328
|
return ok(
|
|
5239
|
-
|
|
5329
|
+
// With an external key the write may have updated the entry, or left it unchanged.
|
|
5330
|
+
[`${args.externalKey ? "Stored" : "Created"} entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, ...notes].join("\n"),
|
|
5240
5331
|
entry
|
|
5241
5332
|
);
|
|
5242
5333
|
})
|
|
@@ -5299,7 +5390,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5299
5390
|
name: "update_content_entry",
|
|
5300
5391
|
config: {
|
|
5301
5392
|
title: "Update a content entry's values",
|
|
5302
|
-
description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list; an image value may carry this placement's focal {x,y} and crop {top,right,bottom,left} (0\u20131). Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, pass merge:true, or read the entry (get_content_entry), change that key and send the whole object back. " + ENTRY_META_NOTE,
|
|
5393
|
+
description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list; an image value may carry this placement's focal {x,y} and crop {top,right,bottom,left} (0\u20131). Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, pass merge:true, or read the entry (get_content_entry), change that key and send the whole object back. " + FIELD_LOCKED_NOTE + " " + ENTRY_META_NOTE,
|
|
5303
5394
|
inputSchema: updateEntryInput.shape
|
|
5304
5395
|
},
|
|
5305
5396
|
handler: guard(
|
|
@@ -5427,7 +5518,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5427
5518
|
name: "create_form",
|
|
5428
5519
|
config: {
|
|
5429
5520
|
title: "Create a form",
|
|
5430
|
-
description: "Create a form in the connected project from a field schema. Confirm the fields with the user first. Field types: text, email, textarea, select (needs options), radio (needs options), checkboxes (needs options), checkbox, number, phone, date, url, consent, hidden. Returns the new form's id. It lands as a DRAFT; call publish_form before embedding (a draft form returns 403 on submit). Embed it with `<BcmsForm form={getForm('Name')} />` from @bettercms-ai/next.",
|
|
5521
|
+
description: "Create a form in the connected project from a field schema. Confirm the fields with the user first. Field types: text, email, textarea, select (needs options), radio (needs options), checkboxes (needs options), checkbox, number, phone, date, url, consent, hidden, step (a divider: the fields after it are one page). Returns the new form's id. It lands as a DRAFT; call publish_form before embedding (a draft form returns 403 on submit). Embed it with `<BcmsForm form={getForm('Name')} />` from @bettercms-ai/next.",
|
|
5431
5522
|
inputSchema: createFormInput.shape
|
|
5432
5523
|
},
|
|
5433
5524
|
handler: guard(
|
|
@@ -5486,7 +5577,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5486
5577
|
name: "update_layout",
|
|
5487
5578
|
config: {
|
|
5488
5579
|
title: "Apply Layout draft commands",
|
|
5489
|
-
description: "Apply permission-shaped values/composition/schema commands to the Global Layout or a page override. Draft-only: this never publishes. Pass the revision from get_layout as ifMatch. ONE command in `command`, or an ordered batch in `commands` (prefer the batch for more than three of anything \u2014 placing site chrome is add-section, add-component and set-bindings in sequence): each command is applied against the revision the previous one RETURNED, so you do not re-read get_layout between them. A batch stops at the first failure and tells you how many were applied and the revision it stopped at \u2014 the earlier ones stand, because a Layout command is not a transaction.",
|
|
5580
|
+
description: "Apply permission-shaped values/composition/schema commands to the Global Layout or a page override. Draft-only: this never publishes. Pass the revision from get_layout as ifMatch. ONE command in `command`, or an ordered batch in `commands` (prefer the batch for more than three of anything \u2014 placing site chrome is add-section, add-component and set-bindings in sequence): each command is applied against the revision the previous one RETURNED, so you do not re-read get_layout between them. A batch stops at the first failure and tells you how many were applied and the revision it stopped at \u2014 the earlier ones stand, because a Layout command is not a transaction. " + LAYOUT_LOCK_NOTE,
|
|
5490
5581
|
inputSchema: commandLayoutInput.shape
|
|
5491
5582
|
},
|
|
5492
5583
|
handler: guard(async (args) => withClient(async (client) => {
|
|
@@ -5524,7 +5615,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5524
5615
|
name: "publish_layout",
|
|
5525
5616
|
config: {
|
|
5526
5617
|
title: "Publish the Global Layout draft",
|
|
5527
|
-
description: "Publish the connected project's GLOBAL Layout draft (navigation, footer, every reserved section). On a site BetterCMS serves, a Layout publish never builds: it writes the VALUES into the live pages, and a STRUCTURE change (Sections added, removed, moved) waits for a Rebuild or a push (`layout-rebuild-required`); a custom host gets its deploy hook. Until published, get_layout copy:'published' answers PUBLISHED_LAYOUT_UNAVAILABLE. Pass get_layout's `revision` as ifMatch; a stale one returns 409 \u2014 re-read, never retry blindly. A 422 lists validation issues (global, or per page override: pageSlug, copy) to fix with update_layout. A new project's chrome is draft: publish_component navigation-default and footer-default first (
|
|
5618
|
+
description: "Publish the connected project's GLOBAL Layout draft (navigation, footer, every reserved section). On a site BetterCMS serves, a Layout publish never builds: it writes the VALUES into the live pages, and a STRUCTURE change (Sections added, removed, moved) waits for a Rebuild or a push (`layout-rebuild-required`); a custom host gets its deploy hook. Until published, get_layout copy:'published' answers PUBLISHED_LAYOUT_UNAVAILABLE. Pass get_layout's `revision` as ifMatch; a stale one returns 409 \u2014 re-read, never retry blindly. A 422 lists validation issues (global, or per page override: pageSlug, copy) to fix with update_layout. A new project's chrome is draft: publish_component navigation-default and footer-default first (the order and the optional logo: skill components). Verify with get_layout copy:'published' \u2014 this response is the write's echo, not a receipt. Page Layout overrides go live with the page (update_page status:'published'). 403 PUBLISH_NOT_GRANTED: this connection may author drafts, not publish \u2014 say so; the user allows it or publishes from the dashboard.",
|
|
5528
5619
|
inputSchema: publishLayoutInput.shape
|
|
5529
5620
|
},
|
|
5530
5621
|
handler: guard(async (args) => withClient(async (client) => {
|
|
@@ -5678,14 +5769,17 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5678
5769
|
try {
|
|
5679
5770
|
const written = await client.fetchJSON(
|
|
5680
5771
|
client.url(`/management/pages/${pageId}/meta`),
|
|
5681
|
-
{ method: "PATCH", body: JSON.stringify({ blockJson: page.blockJson }) }
|
|
5772
|
+
{ method: "PATCH", body: JSON.stringify({ blockJson: page.blockJson, ...args.dryRun ? { dryRun: true } : {} }) }
|
|
5682
5773
|
);
|
|
5683
|
-
results.push({ ok: true, pageId, slug: written.data?.slug ?? page.slug, path: written.data?.path, blocks: page.blockJson.length });
|
|
5774
|
+
results.push({ ok: true, ...args.dryRun ? { status: "would-update" } : {}, pageId, slug: written.data?.slug ?? page.slug, path: written.data?.path, blocks: page.blockJson.length });
|
|
5684
5775
|
} catch (err) {
|
|
5685
5776
|
results.push({ ok: false, pageId, slug: page.slug, error: errorText(err) });
|
|
5686
5777
|
}
|
|
5687
5778
|
}
|
|
5688
5779
|
const failed = results.filter((r) => !r.ok).length;
|
|
5780
|
+
if (args.dryRun) {
|
|
5781
|
+
return ok(DRY_RUN_SUMMARY, { dryRun: true, results, wouldCompose: results.length - failed, failed });
|
|
5782
|
+
}
|
|
5689
5783
|
return ok(
|
|
5690
5784
|
`Composed ${results.length - failed} of ${args.pages.length} page(s)${failed ? `; ${failed} failed \u2014 see the per-item errors` : ""}. These are page DRAFTS: publish each with update_page status:'published'. Then call get_site_composition for the receipt.`,
|
|
5691
5785
|
results
|
|
@@ -5859,7 +5953,7 @@ ${lines.join("\n")}`, found);
|
|
|
5859
5953
|
name: "publish_component",
|
|
5860
5954
|
config: {
|
|
5861
5955
|
title: "Publish a component",
|
|
5862
|
-
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
|
|
5956
|
+
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 asks for exact validation evidence and a human's approval, never of a component you just made with create_component (which ones: skill components). 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.",
|
|
5863
5957
|
inputSchema: getComponentInput.shape
|
|
5864
5958
|
},
|
|
5865
5959
|
handler: guard(
|
|
@@ -6801,13 +6895,13 @@ delivered value \u2014 they change editor chrome only.
|
|
|
6801
6895
|
{ "key": "testimonials", "label": "Testimonials", "type": "repeater",
|
|
6802
6896
|
"ui": { "collapsed": true, "preview": { "title": "author", "media": "avatar" },
|
|
6803
6897
|
"layout": "grid", "icon": "double-quote", "itemLabel": "Testimonial" },
|
|
6804
|
-
"helpText": "Three to five.
|
|
6898
|
+
"helpText": "Three to five.",
|
|
6805
6899
|
"fields": [ { "key": "author", ... }, { "key": "avatar", "type": "image", ... }, ... ] }
|
|
6806
6900
|
\`\`\`
|
|
6807
6901
|
|
|
6808
|
-
- **\`helpText\`** \u2014
|
|
6809
|
-
|
|
6810
|
-
"The title field"
|
|
6902
|
+
- **\`helpText\`** \u2014 the exception: most fields need none. Write it only when the author needs it to
|
|
6903
|
+
type the right value: a format or unit ("Price in cents."), a placement the canvas cannot show, a
|
|
6904
|
+
real constraint, a fallback. Never restate the label ("The title field"), never coach ("Keep it short").
|
|
6811
6905
|
- **\`showIf\`** \u2014 hide a field until another has a value:
|
|
6812
6906
|
\`{match:'all',rules:[{field:'link_type',op:'eq',value:'external'}]}\`. Rules name fields by
|
|
6813
6907
|
BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
|
|
@@ -7130,7 +7224,7 @@ Author a form (then the user embeds it with \`<BcmsForm form={getForm('Name')} /
|
|
|
7130
7224
|
1. **Discover** \u2014 \`list_forms\` to see existing forms; \`get_form\` to read one before editing.
|
|
7131
7225
|
2. **Collect fields (loop)** \u2014 for each: key (machine key for the value), label, type. Types:
|
|
7132
7226
|
text, email, textarea, select (needs \`options: string[]\`), checkbox, checkboxes (needs
|
|
7133
|
-
\`options\`), radio (needs \`options\`), number, phone, date, url, consent, hidden. Optional per
|
|
7227
|
+
\`options\`), radio (needs \`options\`), number, phone, date, url, consent, hidden, step (divider). Optional per
|
|
7134
7228
|
field: required?, placeholder?, helpText? (shown under the input), defaultValue?,
|
|
7135
7229
|
\`validation\` (\`emailPolicy\` on email, \`min\`/\`max\` on number, \`phoneFormat\` on phone,
|
|
7136
7230
|
\`pattern\` on text/textarea/url \u2014 a rule on any other type is a 400),
|
|
@@ -7156,8 +7250,9 @@ error-prone, so go slow and confirm. Never guess the layout.
|
|
|
7156
7250
|
the same prop keys across a family or a swap drops content.
|
|
7157
7251
|
4. **Props** \u2014 declare only what should really be editable:
|
|
7158
7252
|
{ key, label, target: { blockId, path },
|
|
7159
|
-
type: text|richtext|image|url|boolean|number|select|group|table|slot|form }.
|
|
7160
|
-
config.options; 'group'/'table' need config.fields; '
|
|
7253
|
+
type: text|richtext|image|url|boolean|number|select|group|table|slot|form|link|button|date|color|file|icon|multiselect }.
|
|
7254
|
+
'select'/'multiselect' need config.options; 'group'/'table' need config.fields; a 'button'
|
|
7255
|
+
is ONE prop on a button block's 'props', never a label prop plus a url prop; 'slot' holds ONE nested component (a list with config.multiple);
|
|
7161
7256
|
'form' holds ONE Form document \u2014 point target.path at 'props' on an empty form block and set
|
|
7162
7257
|
defaultValue to {formId}. Never re-declare a form's FIELDS as props: the Form document owns
|
|
7163
7258
|
them, and a second copy is a second source of truth that drifts on the first edit.
|