@bettercms-ai/mcp 0.69.0 → 0.71.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 +195 -75
- package/dist/index.js.map +1 -1
- package/dist/skills/bettercms/SKILL.md +10 -4
- package/dist/skills/bettercms/references/animation.md +235 -0
- package/dist/skills/bettercms/references/astro.md +7 -3
- package/dist/skills/bettercms/references/bindings.md +80 -3
- package/dist/skills/bettercms/references/componentize.md +6 -4
- package/dist/skills/bettercms/references/components.md +58 -11
- package/dist/skills/bettercms/references/content-modeling.md +17 -9
- package/dist/skills/bettercms/references/detection.md +4 -4
- package/dist/skills/bettercms/references/editor-chrome.md +4 -0
- package/dist/skills/bettercms/references/finish-site.md +80 -2
- 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 +22 -1
- package/dist/skills/bettercms/references/next.md +6 -5
- package/dist/skills/bettercms/references/publishing.md +46 -0
- package/dist/skills/bettercms/references/site-blueprints.md +18 -4
- package/dist/skills/bettercms/references/worked-examples-advanced.md +17 -3
- package/dist/skills/bettercms/references/worked-examples.md +26 -11
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -36,7 +36,27 @@ var COMPONENT_PROP_TYPES = [
|
|
|
36
36
|
"color",
|
|
37
37
|
"file",
|
|
38
38
|
"icon",
|
|
39
|
-
"multiselect"
|
|
39
|
+
"multiselect",
|
|
40
|
+
// V2 schema language (F1.x). `object` is ONE nested value of named `config.fields`, stored as
|
|
41
|
+
// `{ [childKey]: value }` like the model field `object`; the six after it store what the
|
|
42
|
+
// same-named content-model FIELD stores (`location` is the V2 geopoint) and are judged by
|
|
43
|
+
// that field's validator.
|
|
44
|
+
"object",
|
|
45
|
+
"datetime",
|
|
46
|
+
"slug",
|
|
47
|
+
"email",
|
|
48
|
+
"phone",
|
|
49
|
+
"json",
|
|
50
|
+
"location",
|
|
51
|
+
// V2 leaves, each storing what the same-named content-model FIELD stores: a taxonomy string[],
|
|
52
|
+
// a rating number, a code value (`{ language?, code }` or a string).
|
|
53
|
+
"tags",
|
|
54
|
+
"rating",
|
|
55
|
+
"code",
|
|
56
|
+
// CMS items, as the same-named content-model FIELDS store them: an entry id (or a polymorphic
|
|
57
|
+
// `{ entryId, modelSlug }`), and a list of them. Delivery hydrates them like an entry's.
|
|
58
|
+
"reference",
|
|
59
|
+
"multi-reference"
|
|
40
60
|
];
|
|
41
61
|
|
|
42
62
|
// ../types/src/layout-lucide-icons.ts
|
|
@@ -2277,8 +2297,8 @@ var STRUCTURE_PLAYBOOK_URI = "bettercms://playbook/structure";
|
|
|
2277
2297
|
var STRUCTURE_DEFAULT_INSTRUCTION = `After you create collections or pages, organise them per the structure playbook: read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure (read-only), show the user its outline, then apply it with set_content_structure and the version it returned (the If-Match). A project that already has a structure keeps it: file only what you created, with move_to_folder, unless the user asks for a full re-organisation.`;
|
|
2278
2298
|
var SKILLS_ROUTING_INSTRUCTION = "Before writing code in a repo that uses BetterCMS, explore the repo, then read the installed `bettercms` skill (`npx @bettercms-ai/install --project -y` adds it if missing): its table turns what you found into the one reference the task needs \u2014 read only that. Without the skill installed, read the same files as resources: bettercms://skill/bettercms, then bettercms://skill/<reference>.";
|
|
2279
2299
|
var DOCK_UI_ROUTING_INSTRUCTION = "How fields and props look in the editor (groups, tabs, rows, icons, item names) is playbook \xA714 or the `field-ui` prompt: write it with update_content_model field* or update_component prop* plus ifMatch, read it back, and finish when get_next_steps reports no dock-* step.";
|
|
2280
|
-
var RECEIPT_FIRST_INSTRUCTION = "Start every project with get_next_steps: its `receipt` lists what is already done (`done`), what is still missing (`missing`, each item citing the count it reacted to) and the one `nextAction`. Work from that receipt instead of re-reading the site, call get_next_steps again after each batch of edits, and before you tell the user you are finished.
|
|
2281
|
-
var NEXT_STEPS_DESCRIPTION = `Call this FIRST on a project. Returns \`receipt\` \u2014 \`done\` (live release, bound elements, conversion coverage, composed pages, published components and Layout), \`missing\`, \`nextAction\` and \`notes\` (unjudged: unknown, not done) \u2014 plus \`data\`, the unfinished items: missing meta descriptions, drafts, empty collections, unnotified forms, writes awaiting approval, unstamped bindings or sections, \`authoring-undecided\` and the Layout lane (\`layout-write-blocked\`, \`layout-field-unread\`, \`layout-locator-stale\`, \`layout-rebuild-required\`, \`chrome-duplicated\`). Each item cites its count and may carry \`payload\` {rows, total}, at most 20 rows. Call it again after
|
|
2300
|
+
var RECEIPT_FIRST_INSTRUCTION = "Start every project with get_next_steps: its `receipt` lists what is already done (`done`), what is still missing (`missing`, each item citing the count it reacted to) and the one `nextAction`. Work from that receipt instead of re-reading the site, call get_next_steps again after each batch of edits, and before you tell the user you are finished. On an imported site you are finished only when `receipt.complete` is true: work `receipt.gate.blocking` in order, and record a gap you cannot close with editor_setup `exempt` (an allowed reason and a note), never in prose.";
|
|
2301
|
+
var NEXT_STEPS_DESCRIPTION = `Call this FIRST on a project. Returns \`receipt\` \u2014 \`done\` (live release, bound elements, conversion coverage, composed pages, published components and Layout), \`missing\`, \`nextAction\` and \`notes\` (unjudged: unknown, not done) \u2014 plus \`data\`, the unfinished items: missing meta descriptions, drafts, empty collections, unnotified forms, writes awaiting approval, unstamped bindings or sections, \`authoring-undecided\` and the Layout lane (\`layout-write-blocked\`, \`layout-field-unread\`, \`layout-locator-stale\`, \`layout-rebuild-required\`, \`chrome-duplicated\`). Each item cites its count and may carry \`payload\` {rows, total}, at most 20 rows. \`receipt.complete\` is the setup gate's verdict and \`receipt.gate\` its numbers: per built route the page, text, image and link coverage, \`blocking\` first, each with its \`fix\`. Call it again after each batch; you are finished only when \`complete\` is true. \`organise-content\`: pages and collections are in no folder \u2014 read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure and, once the user agrees, set_content_structure.`;
|
|
2282
2302
|
var LAYOUT_CHROME_INSTRUCTION = 'When the user asks to make the navigation, footer or other site chrome editable, or get_next_steps lists a `layout-*` or `chrome-duplicated` item: this is playbook \xA711 item 3. Chrome is the project Layout, never page fields: in the source, wrap each Section in `data-bcms-layout-section` (the slot, its base slug) and mark each value `data-bcms-layout-field="layout:<sections[slug].id>:<fieldId>"`, never `data-bcms-field` inside it, then push; the strip removes the page copies, never delete them by hand. On a site BetterCMS serves, publish_layout writes Layout VALUES into the live pages; a structure change waits for a Rebuild. Never place nav, footer or modal blocks or fields on a page: the Layout (or the site\'s own layout) draws chrome, so a page copy either duplicates it or renders nowhere (get_componentize_plan reports one as LAYOUT_CHROME).';
|
|
2283
2303
|
var CTA_BUTTON_INSTRUCTION = "A call to action is ONE `button` field (value {label, href, variant?, size?, arrow?, icon?, newTab?}), rendered with BcmsButton from @bettercms-ai/astro or @bettercms-ai/next: never a text field beside a link field. A page that already holds such a pair (primaryCtaText + primaryCtaHref) becomes one button with update_field `mergeHref`. Inside a component a CTA is a `button` block whose text and href two props target.";
|
|
2284
2304
|
var STRUCTURE_EXAMPLE_PAYLOAD = {
|
|
@@ -2519,7 +2539,7 @@ function allKeys(fields) {
|
|
|
2519
2539
|
})
|
|
2520
2540
|
]);
|
|
2521
2541
|
}
|
|
2522
|
-
var CLEARABLE = ["helpText", "showIf", "ui", "placement", "readOnly", "lockReason"];
|
|
2542
|
+
var CLEARABLE = ["helpText", "showIf", "ui", "placement", "readOnly", "lockReason", "helpMore", "advanced", "recommendedMax", "openMode"];
|
|
2523
2543
|
var SETTABLE = ["label", "required", ...CLEARABLE];
|
|
2524
2544
|
function mergeConfig(current, patch) {
|
|
2525
2545
|
const out = { ...current ?? {} };
|
|
@@ -2557,7 +2577,7 @@ function planFieldEdit(model, edit, callerIfMatch) {
|
|
|
2557
2577
|
}
|
|
2558
2578
|
const changes = SETTABLE.filter((k) => edit[k] !== void 0);
|
|
2559
2579
|
if (!retype && rename === void 0 && edit.config === void 0 && changes.length === 0) {
|
|
2560
|
-
return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, readOnly, lockReason, type or key." };
|
|
2580
|
+
return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, readOnly, lockReason, helpMore, advanced, recommendedMax, openMode, type or key." };
|
|
2561
2581
|
}
|
|
2562
2582
|
const echo = helpTextEcho(edit.helpText, { label: edit.label ?? current.label, defaultValue: current.defaultValue });
|
|
2563
2583
|
if (echo) return { ok: false, error: echo };
|
|
@@ -2673,8 +2693,10 @@ function listsTool(name, sets) {
|
|
|
2673
2693
|
}
|
|
2674
2694
|
|
|
2675
2695
|
// src/tool-text.ts
|
|
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. To change a field that exists, use update_field.";
|
|
2696
|
+
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`; `icon` is a BetterCMS icon name or an uploaded icon. To change a field that exists, use update_field. V2 keys (object, openMode, advanced, helpMore, recommendedMax, config.scale): \xA714 of bettercms://playbook/schema.";
|
|
2697
|
+
var V2_LEAF_TYPES_NOTE = "`tags`: string[] taxonomy (config.options suggestions, max, allowCustom). `rating`: 0..config.max (5), step 1|0.5. `code`: {language?, code} or a string (config.languages).";
|
|
2677
2698
|
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.";
|
|
2699
|
+
var WINDOW_NOTE = "{from?, until?}, ISO 8601 with offset: shown only between the two instants; null clears (skill publishing).";
|
|
2678
2700
|
var WEBHOOK_EVENTS = [
|
|
2679
2701
|
"content.publish",
|
|
2680
2702
|
"content.unpublish",
|
|
@@ -2715,11 +2737,17 @@ var PARAM_TEXT = {
|
|
|
2715
2737
|
lockReason: "which system owns the value (\u2264 200 chars)",
|
|
2716
2738
|
lockClears: "as on add_field; null clears it",
|
|
2717
2739
|
helpText: "shown under the field: its purpose, where it shows, any constraint; never its label or value. Omitted: written for you.",
|
|
2740
|
+
helpMore: "longer help behind the \u24D8 next to the label, when one helpText line cannot carry it",
|
|
2741
|
+
advanced: "true = a rare or developer setting: it collects in a closed 'Advanced' section",
|
|
2742
|
+
recommendedMax: "a SOFT limit (characters, items or value): the editor warns past it, never refuses",
|
|
2743
|
+
openMode: "object/array/group/repeater: how the value (or each row) opens: 'inline', 'drill', 'popover' or 'dialog'",
|
|
2744
|
+
fromBuiltin: "a builtin:* id from list_components: copies it into the project; fields you send win",
|
|
2718
2745
|
describes: "for editors: what it is and holds; omitted: written for you",
|
|
2719
2746
|
runtimeBundle: "instead of pageId: the server-app bundle cleanup",
|
|
2720
2747
|
pageRef: "page id or path ('/de/about'); a bare slug only when one page has it",
|
|
2721
2748
|
editorSetupForce: "re-run a setup already ready for the live build",
|
|
2722
2749
|
editorSetupCheck: "true = only report the state; starts nothing",
|
|
2750
|
+
editorSetupExempt: "instead of running: record setup-gate exceptions [{ route, check, reason, note }]; reason null removes one. Reasons: get_next_steps `receipt.gate`",
|
|
2723
2751
|
pageDynamic: "a dynamic (collection template) page only",
|
|
2724
2752
|
previewPath: "its route pattern, e.g. /blog/{slug}; null clears",
|
|
2725
2753
|
pageModelId: "an existing collection this page lists (when it has no model yet)",
|
|
@@ -2729,7 +2757,7 @@ var PARAM_TEXT = {
|
|
|
2729
2757
|
sort: "comma list, a leading - sorts descending, e.g. '-publishedAt,title'",
|
|
2730
2758
|
page: "1-based page of `limit` rows; the response's meta says totalPages",
|
|
2731
2759
|
locale: "language, a BCP-47 tag (zh-CN \u2192 zh-cn), one of the project's locales; null = the default",
|
|
2732
|
-
translationOf: "the SOURCE
|
|
2760
|
+
translationOf: "the SOURCE id this translates (never a translation); null unlinks. One per locale: the translation sets locale and translationOf",
|
|
2733
2761
|
pageScheduledAt: "ISO datetime to publish this page's draft at (needs publish permission); null cancels. An edit that omits scheduledAt CANCELS a pending schedule",
|
|
2734
2762
|
pageUnpublishAt: "ISO datetime at which the published page returns to draft; null cancels; omitting it never cancels it",
|
|
2735
2763
|
sourceUrl: "instead of data: a public http(s) URL the server fetches and stores (private or internal addresses are refused)",
|
|
@@ -2739,8 +2767,9 @@ var PARAM_TEXT = {
|
|
|
2739
2767
|
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"
|
|
2740
2768
|
};
|
|
2741
2769
|
var INPUT_CONFIRMED_HEADER = "x-bcms-confirmed-input";
|
|
2742
|
-
var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant
|
|
2743
|
-
var
|
|
2770
|
+
var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant? (a Button family look), size?, arrow?, icon?, newTab?, rel?} (arrow:false, icon:null off): never a text field beside a link field.";
|
|
2771
|
+
var LINK_VALUE_NOTE = "A link or button href: a URL string or {type: url|page|entry|email|phone|anchor|file, href, title?, newTab?, rel?, section?, ref: page/entry id}.";
|
|
2772
|
+
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, lockReason, or the V2 helpMore, advanced, recommendedMax and openMode (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`.";
|
|
2744
2773
|
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.";
|
|
2745
2774
|
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.";
|
|
2746
2775
|
var SEO_KEY_TEXT = {
|
|
@@ -2755,6 +2784,9 @@ var SEO_KEY_TEXT = {
|
|
|
2755
2784
|
var VARIANT_GROUP_NOTE = "When the component belongs to a family of looks, `variantGroup` names the family and lists its looks.";
|
|
2756
2785
|
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.";
|
|
2757
2786
|
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.";
|
|
2787
|
+
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.";
|
|
2788
|
+
var PROP_V2_NOTE = "Props take the fields' V2 keys (\xA714): 'object' = ONE {child: value} from config.fields, \u226410 levels (field_depth_exceeded); datetime|slug|email|phone|json|location|tags|rating|code|reference|multi-reference as that field (a reference names contentModelId, or polymorphic + allowedModelSlugs; page reads hydrate its entries); helpMore, advanced, recommendedMax, openMode, config.scale, ui.layout rows|chips|gallery, ui.preview.fallback, showIf notEmpty|empty.";
|
|
2789
|
+
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`.";
|
|
2758
2790
|
|
|
2759
2791
|
// src/entry-query.ts
|
|
2760
2792
|
var text = (v) => Array.isArray(v) ? v.map(String).join(",") : String(v);
|
|
@@ -2916,8 +2948,17 @@ var fieldType = z.enum([
|
|
|
2916
2948
|
"color",
|
|
2917
2949
|
"json",
|
|
2918
2950
|
"file",
|
|
2919
|
-
"sections"
|
|
2951
|
+
"sections",
|
|
2952
|
+
"multiselect",
|
|
2953
|
+
"icon",
|
|
2954
|
+
// V2: ONE nested object of `fields`, kept as `object` (not normalized to zones). `toField` recurses into it.
|
|
2955
|
+
"object",
|
|
2956
|
+
// V2 leaves: a taxonomy string[], a rating number, a code value. The pass-through branch carries their config.
|
|
2957
|
+
"tags",
|
|
2958
|
+
"rating",
|
|
2959
|
+
"code"
|
|
2920
2960
|
]);
|
|
2961
|
+
var OPEN_MODE_INVALID = "openMode_invalid: openMode must be one of 'inline', 'drill', 'popover', 'dialog'";
|
|
2921
2962
|
var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
|
|
2922
2963
|
var fieldKey = z.string().regex(/^[a-zA-Z0-9_-]+$/, "letters, numbers, underscores, and hyphens only");
|
|
2923
2964
|
var uiObject = z.strictObject({
|
|
@@ -2925,23 +2966,24 @@ var uiObject = z.strictObject({
|
|
|
2925
2966
|
preview: z.strictObject({
|
|
2926
2967
|
title: z.string().min(1).optional().describe("CHILD KEY whose value titles a collapsed row"),
|
|
2927
2968
|
subtitle: z.string().min(1).optional().describe("CHILD KEY whose value is the row's second line (e.g. 'level' \u2192 'Heading \xB7 h2')"),
|
|
2928
|
-
media: z.string().min(1).optional().describe("CHILD KEY whose value is the row's thumbnail (image/file/url all work)")
|
|
2969
|
+
media: z.string().min(1).optional().describe("CHILD KEY whose value is the row's thumbnail (image/file/url all work)"),
|
|
2970
|
+
fallback: z.string().min(1).max(60).optional().describe("a literal row label while the title child is empty ('Untitled question'), not a key")
|
|
2929
2971
|
}).optional().describe(
|
|
2930
2972
|
"how ONE ITEM summarises itself when collapsed. Every slot names a CHILD KEY of THIS field/prop \u2014 not a value, not a dotted path. Without it a repeater of testimonials reads 'Item 1, Item 2, Item 3'; with {title:'author', media:'avatar'} it reads the names with their faces."
|
|
2931
2973
|
),
|
|
2932
|
-
layout: z.enum(["list", "grid", "table"]).optional().describe(
|
|
2933
|
-
"how the ITEMS are arranged. Omit (\u2261 'list') for rows of text; 'grid' for items whose thumbnail is the thing you scan \u2014 a gallery, a logo wall, a team; 'table' for up to 3 scalar columns. Same placement rule as `preview`."
|
|
2974
|
+
layout: z.enum(["list", "grid", "table", "rows", "chips", "gallery"]).optional().describe(
|
|
2975
|
+
"how the ITEMS are arranged. Omit (\u2261 'list') for rows of text; 'grid' for items whose thumbnail is the thing you scan \u2014 a gallery, a logo wall, a team; 'table' for up to 3 scalar columns. Same placement rule as `preview`. V2 'rows' | 'chips' | 'gallery' are also valid on a primitive array."
|
|
2934
2976
|
),
|
|
2935
2977
|
reorderable: z.boolean().optional().describe(
|
|
2936
2978
|
"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."
|
|
2937
2979
|
),
|
|
2938
|
-
control: z.enum(["segmented", "slider"]).optional().describe(
|
|
2939
|
-
"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."
|
|
2980
|
+
control: z.enum(["segmented", "slider", "rating"]).optional().describe(
|
|
2981
|
+
"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."
|
|
2940
2982
|
),
|
|
2941
2983
|
// A refine, not an enum: 236 names inlined into every schema that carries `ui` would be paid on
|
|
2942
2984
|
// every tools/list. The layoutIcon precedent; the server's z.enum is the guard.
|
|
2943
2985
|
icon: z.string().min(1).max(40).refine((v) => DOCK_ICON_SET.has(v), `not a dock icon, see ${DOCK_ICONS_URI}`).optional().describe(`the branch row's icon, a name from ${DOCK_ICONS_URI}. Nesting fields only.`),
|
|
2944
|
-
itemLabel: z.string().min(1).max(24).optional().describe("what ONE item is called ('Slide'): the add button, crumbs and empty state. Nesting fields only."),
|
|
2986
|
+
itemLabel: z.string().min(1).max(24).optional().describe("what ONE item is called ('Slide'): the add button, crumbs and empty state. Nesting fields only. V2 itemNoun goes here."),
|
|
2945
2987
|
input: z.object({
|
|
2946
2988
|
name: z.string().regex(/^[a-z][a-z0-9-]{0,47}$/),
|
|
2947
2989
|
writes: z.array(z.string().min(1).max(64)).max(16).optional()
|
|
@@ -2958,23 +3000,24 @@ var propUiObject = uiObject.extend({
|
|
|
2958
3000
|
collapsed: z.boolean().optional()
|
|
2959
3001
|
}).optional().describe("the group this prop sits in. The FIRST prop of a group defines {id, label, kind?, icon?, collapsed?}; later props send only {id}.")
|
|
2960
3002
|
});
|
|
3003
|
+
var choiceOption = z.union([z.string(), z.object({ label: z.string(), value: z.string(), icon: z.string().optional(), description: z.string().optional() })]);
|
|
2961
3004
|
var fieldShape = {
|
|
2962
3005
|
key: fieldKey.describe(
|
|
2963
3006
|
"machine field key. \u{1F534} UNIQUE ACROSS THE WHOLE MODEL \u2014 API IDs share ONE FLAT NAMESPACE, so a field nested inside a group must NOT reuse a key used by another field or group. Every section's heading cannot be 'title'. Prefix with the section: 'hero_title', 'pricing_title', 'faq_title'. Reusing a key is refused, and if it slips through it leaves permanent errors in the editor and breaks conditional-visibility rules, which reference fields by bare key. Keys derived from an imported site may contain hyphens ('hero-h1-welcome'): address them exactly as returned."
|
|
2964
3007
|
),
|
|
2965
3008
|
label: z.string().min(1).describe("human label shown in the editor"),
|
|
2966
|
-
type: fieldType.describe(`field type. ${BUTTON_FIELD_NOTE}`),
|
|
3009
|
+
type: fieldType.describe(`field type. ${BUTTON_FIELD_NOTE} ${LINK_VALUE_NOTE}`),
|
|
2967
3010
|
required: z.boolean().optional().describe(
|
|
2968
3011
|
"an entry cannot be saved without a value \u2014 set it only where the site genuinely cannot render without one"
|
|
2969
3012
|
),
|
|
2970
3013
|
richText: z.boolean().optional().describe(
|
|
2971
3014
|
"prose formatting. DEFAULTS TO TRUE for 'text': the field is created as rich text so editors can bold, link and format it on the canvas, and the API returns rich text (render with rich() from @bettercms-ai/sdk; plain() for titles and meta). Pass false for a value that is NOT prose and must stay a bare string \u2014 a URL/href, a slug, an id, an email, a phone number, a CSS class, an icon name. A link stored as rich text will not work as an href."
|
|
2972
3015
|
),
|
|
2973
|
-
options: z.array(
|
|
2974
|
-
"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'})."
|
|
3016
|
+
options: z.array(choiceOption).optional().describe(
|
|
3017
|
+
"choices when type is 'select'. A plain string, or {label, value, icon?, description?} when the stored value differs from what the editor shows (e.g. {label:'Sold out', value:'soldOut'}); icon a dock name, description one line \u2264140."
|
|
2975
3018
|
),
|
|
2976
3019
|
config: z.record(z.string(), z.unknown()).optional().describe(
|
|
2977
|
-
"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"
|
|
3020
|
+
"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; limits: text/longtext {minLength,maxLength,pattern,patternMessage}, richtext {minLength,maxLength}, number {validation:{min,max}}, array {minItems,maxItems}, tags {min,max}, rating {min,max,step}"
|
|
2978
3021
|
),
|
|
2979
3022
|
fields: z.array(z.lazy(() => fieldObject)).optional().describe(
|
|
2980
3023
|
"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."
|
|
@@ -2990,8 +3033,8 @@ var fieldShape = {
|
|
|
2990
3033
|
rules: z.array(
|
|
2991
3034
|
z.object({
|
|
2992
3035
|
field: z.string().describe("the BARE KEY of another field on this same model"),
|
|
2993
|
-
op: z.enum(["eq", "neq", "in", "isSet"]),
|
|
2994
|
-
value: z.union([z.string(), z.array(z.string())]).optional().describe("omit for 'isSet' (the source holds any value); required for every other op")
|
|
3036
|
+
op: z.enum(["eq", "neq", "in", "isSet", "notEmpty", "empty"]),
|
|
3037
|
+
value: z.union([z.string(), z.array(z.string())]).optional().describe("omit for 'isSet'/'notEmpty' (the source holds any value) and 'empty' (it holds none); required for every other op")
|
|
2995
3038
|
})
|
|
2996
3039
|
)
|
|
2997
3040
|
}).optional().describe(
|
|
@@ -3010,10 +3053,15 @@ var fieldShape = {
|
|
|
3010
3053
|
defaultValue: z.unknown().optional().describe("the value a new entry starts with"),
|
|
3011
3054
|
searchable: z.boolean().optional().describe("omit (\u2261 true) to index this field for site search; false excludes it"),
|
|
3012
3055
|
unique: z.boolean().optional().describe(
|
|
3013
|
-
"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
|
|
3056
|
+
"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, number, select, reference and date; on a list it means no item repeats. A clash is refused with 409 `unique_conflict`."
|
|
3014
3057
|
),
|
|
3015
3058
|
readOnly: z.union([z.literal(true), z.literal("developer")]).optional().describe(PARAM_TEXT.readOnly),
|
|
3016
|
-
lockReason: z.string().max(200).optional().describe(PARAM_TEXT.lockReason)
|
|
3059
|
+
lockReason: z.string().max(200).optional().describe(PARAM_TEXT.lockReason),
|
|
3060
|
+
// V2 schema language. Editor-only; the playbook's §14 table maps each to what the editor draws.
|
|
3061
|
+
helpMore: z.string().max(2e3).optional().describe(PARAM_TEXT.helpMore),
|
|
3062
|
+
advanced: z.boolean().optional().describe(PARAM_TEXT.advanced),
|
|
3063
|
+
recommendedMax: z.number().int().positive().optional().describe(PARAM_TEXT.recommendedMax),
|
|
3064
|
+
openMode: z.enum(["inline", "drill", "popover", "dialog"], { error: OPEN_MODE_INVALID }).optional().describe(PARAM_TEXT.openMode)
|
|
3017
3065
|
};
|
|
3018
3066
|
var fieldObject = z.object(fieldShape);
|
|
3019
3067
|
var urlPatternArg = z.string().nullable().optional().describe(
|
|
@@ -3028,13 +3076,16 @@ var fieldsetsArg = z.array(
|
|
|
3028
3076
|
tab: z.string().min(1).optional(),
|
|
3029
3077
|
icon: uiObject.shape.icon,
|
|
3030
3078
|
collapsed: z.boolean().optional(),
|
|
3079
|
+
collapsible: z.boolean().optional(),
|
|
3080
|
+
columns: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
3081
|
+
layout: z.enum(["stack", "breakpoints"]).optional(),
|
|
3031
3082
|
// Server-owned provenance, as get_content_model returns it: an echo parses and is dropped
|
|
3032
3083
|
// before the write (dropProvenance), never refused.
|
|
3033
3084
|
uiOrigin: z.unknown().optional(),
|
|
3034
3085
|
uiBefore: z.unknown().optional()
|
|
3035
3086
|
})
|
|
3036
3087
|
).max(30).optional().describe(
|
|
3037
|
-
"the model's editor cards: [{id, name, description?, kind?, tab?, icon?, collapsed
|
|
3088
|
+
"the model's editor cards: [{id, name, description?, kind?, tab?, icon?, collapsed?, collapsible?, columns?:1|2, layout?:'breakpoints'}], at most 30, ids unique, order = array index. kind 'group' is a disclosure, 'row' puts up to 3 short scalars on one line, 'tab' is a tab (at most 3, top-level fields only; untabbed fields stay pinned above the tabs; a group's `tab` names its tab). A field joins one with `fieldsetId`. Editor-only: never changes what is stored or delivered."
|
|
3038
3089
|
);
|
|
3039
3090
|
var dropProvenance = (sets) => sets?.map(({ uiOrigin: _origin, uiBefore: _before, ...set }) => set) ?? sets;
|
|
3040
3091
|
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.";
|
|
@@ -3120,8 +3171,16 @@ function toField(f) {
|
|
|
3120
3171
|
...f.unique !== void 0 ? { unique: f.unique } : {},
|
|
3121
3172
|
// The developer lock: stripped here, an agent writing a model back would unlock every field.
|
|
3122
3173
|
...f.readOnly !== void 0 ? { readOnly: f.readOnly } : {},
|
|
3123
|
-
...f.lockReason !== void 0 ? { lockReason: f.lockReason } : {}
|
|
3174
|
+
...f.lockReason !== void 0 ? { lockReason: f.lockReason } : {},
|
|
3175
|
+
// V2 editor keys: on `base` so the group/repeater rebuild cannot drop them.
|
|
3176
|
+
...f.helpMore !== void 0 ? { helpMore: f.helpMore } : {},
|
|
3177
|
+
...f.advanced !== void 0 ? { advanced: f.advanced } : {},
|
|
3178
|
+
...f.recommendedMax !== void 0 ? { recommendedMax: f.recommendedMax } : {},
|
|
3179
|
+
...f.openMode !== void 0 ? { openMode: f.openMode } : {}
|
|
3124
3180
|
};
|
|
3181
|
+
if (f.type === "object") {
|
|
3182
|
+
return { ...base, type: "object", fields: toFields(f.fields) };
|
|
3183
|
+
}
|
|
3125
3184
|
if (f.type === "group") {
|
|
3126
3185
|
return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
|
|
3127
3186
|
}
|
|
@@ -3204,7 +3263,7 @@ var GIT_TEXT = {
|
|
|
3204
3263
|
var HOSTING_TEXT = {
|
|
3205
3264
|
list_env_vars: "The project's environment variables by NAME and scope (never a value), and `missing`: the ones the repository's .env.example declares that the project does not set. A site missing them builds and serves, then renders empty or crashes in the browser. Resolve `missing` by running `npx @bettercms-ai/cli@latest env push` in the repository: it uploads those values from the user's own .env files straight to BetterCMS, so they never pass through this conversation. Never read, print or ask the user to paste a value. When the values do not exist locally because the site reads its content from another CMS, move that content into BetterCMS instead (the bettercms skill's external-content reference). BetterCMS's own BCMS_* identifiers are supplied to every build and never need setting.",
|
|
3206
3265
|
list_hosting_connections: "The Vercel, Netlify and Cloudflare accounts connected to this workspace (id, provider, accountLabel, status; never a token), plus `connectUrl`, the dashboard page where a person connects one. Call it before deploy_to_host. If the provider the user wants has no connection with status `ok`, give the user `connectUrl` to connect it in their browser and stop until they have: you never handle OAuth or a host token yourself.",
|
|
3207
|
-
deploy_to_host: "Deploy this project to the user's own Vercel or Netlify account through their connection. BetterCMS builds the site and the host serves that build
|
|
3266
|
+
deploy_to_host: "Deploy this project to the user's own Vercel or Netlify account through their connection. BetterCMS builds the site and the host serves that build (skill publishing). The first call creates the site on the host and makes it the project's host; a later call for the same provider redeploys that site (`redeployed: true`). `connectionId` (from list_hosting_connections) defaults to the newest working one, `siteName` to the project's handle. A refusal carries a `code` and a `fixUrl` (not_connected, reconnect, pick_existing_site, grant_required, ADMIN_REQUIRED): each is fixed by the user at `fixUrl`, so relay it and stop. `deploymentQueued: false` means the project has no live build yet: deploy_project first, and that release reaches the host on its own. Never run the Vercel or Netlify CLI while a connection exists. Then poll get_host_deploy_status. Needs artifact:write, like deploy_project.",
|
|
3208
3267
|
get_host_deploy_status: "The connected project's recent deployments to Vercel, Netlify or Cloudflare, newest first: state (queued|deploying|live|failed), providerUrl, error, sha and targetName. Poll it after deploy_to_host until the newest one is `live`, whose `providerUrl` is the live URL to hand the user, or `failed`, whose `error` you read back to them."
|
|
3209
3268
|
};
|
|
3210
3269
|
function ok(summary, data) {
|
|
@@ -3344,6 +3403,7 @@ function buildToolDefs(deps) {
|
|
|
3344
3403
|
}
|
|
3345
3404
|
};
|
|
3346
3405
|
}
|
|
3406
|
+
const windowArg = z.object({ from: z.string().optional(), until: z.string().optional() });
|
|
3347
3407
|
const blockObject = z.object({
|
|
3348
3408
|
type: z.enum([
|
|
3349
3409
|
"heading",
|
|
@@ -3365,11 +3425,12 @@ function buildToolDefs(deps) {
|
|
|
3365
3425
|
]).describe("block type; section/slider/tabs/columns nest child blocks"),
|
|
3366
3426
|
id: z.string().min(1).describe("stable unique block id"),
|
|
3367
3427
|
props: z.record(z.string(), z.unknown()).describe(
|
|
3368
|
-
"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?
|
|
3428
|
+
"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? (hosted pages; else a Button component), 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?, source? {modelId}, limit?, filters?, sort?, pinnedIds?, excludeCurrent?}: a collection's published entries as cards (every prop: skill content-modeling)"
|
|
3369
3429
|
),
|
|
3370
3430
|
style: z.record(z.string(), z.unknown()).optional().describe(
|
|
3371
|
-
"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."
|
|
3372
|
-
)
|
|
3431
|
+
"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."
|
|
3432
|
+
),
|
|
3433
|
+
window: windowArg.nullable().optional().describe(WINDOW_NOTE)
|
|
3373
3434
|
});
|
|
3374
3435
|
const createPageInput = z.object({
|
|
3375
3436
|
title: z.string().min(1).describe("display title of the page, e.g. 'Home'"),
|
|
@@ -3541,7 +3602,9 @@ function buildToolDefs(deps) {
|
|
|
3541
3602
|
"date",
|
|
3542
3603
|
"url",
|
|
3543
3604
|
"consent",
|
|
3544
|
-
"hidden"
|
|
3605
|
+
"hidden",
|
|
3606
|
+
"step"
|
|
3607
|
+
// `step` is a divider: the fields after it are one page
|
|
3545
3608
|
]),
|
|
3546
3609
|
placeholder: z.string().optional(),
|
|
3547
3610
|
helpText: z.string().optional().describe("hint shown under the control, muted"),
|
|
@@ -3601,16 +3664,16 @@ function buildToolDefs(deps) {
|
|
|
3601
3664
|
// enum omitted could not be echoed back, and an agent reading a component and writing it
|
|
3602
3665
|
// straight back DESTROYED every prop of that type on a 200.
|
|
3603
3666
|
type: z.enum(COMPONENT_PROP_TYPES),
|
|
3604
|
-
// 'slot' holds ONE nested component instance
|
|
3605
|
-
// fill it. Absent here until now, so a slot allowlist was unreachable from stdio even
|
|
3667
|
+
// 'slot' holds ONE nested component instance, or a list with config.multiple (the keys
|
|
3668
|
+
// 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
|
|
3606
3669
|
// once the enum allowed the type.
|
|
3607
3670
|
config: z.object({
|
|
3608
3671
|
componentIds: z.array(z.string()).optional(),
|
|
3609
3672
|
// 'slot' allowlist
|
|
3610
|
-
options: z.array(
|
|
3673
|
+
options: z.array(choiceOption).optional(),
|
|
3611
3674
|
// 'select' — required, choices
|
|
3612
|
-
// 'group'/'table' sub-shape. Typed loosely here (the server validates the full
|
|
3613
|
-
// recursive shape) so a
|
|
3675
|
+
// 'group'/'table'/'object' sub-shape. Typed loosely here (the server validates the full
|
|
3676
|
+
// recursive shape, 10 levels at most) so a deep tree needs no Zod mirror in the client.
|
|
3614
3677
|
fields: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
3615
3678
|
min: z.number().optional(),
|
|
3616
3679
|
// 'number'
|
|
@@ -3631,15 +3694,20 @@ function buildToolDefs(deps) {
|
|
|
3631
3694
|
match: z.enum(["all", "any"]),
|
|
3632
3695
|
rules: z.array(z.object({
|
|
3633
3696
|
field: z.string().min(1),
|
|
3634
|
-
op: z.enum(["eq", "neq", "in", "isSet"]),
|
|
3697
|
+
op: z.enum(["eq", "neq", "in", "isSet", "notEmpty", "empty"]),
|
|
3635
3698
|
value: z.union([z.string(), z.array(z.string())]).optional()
|
|
3636
3699
|
})).min(1).max(20)
|
|
3637
|
-
}).optional().describe("show this prop only while SIBLING props (by key) match: {match:'all'|'any', rules:[{field, op:'eq'|'neq'|'in'|'isSet', value?}]}. 'isSet'
|
|
3700
|
+
}).optional().describe("show this prop only while SIBLING props (by key) match: {match:'all'|'any', rules:[{field, op:'eq'|'neq'|'in'|'isSet'|'notEmpty'|'empty', value?}]}. 'isSet', 'notEmpty' and 'empty' take no value. Editor-only."),
|
|
3701
|
+
// V2 editor-only keys, the model field's (playbook §14). Declared: this is a strip site.
|
|
3702
|
+
helpMore: z.string().max(2e3).optional().describe(PARAM_TEXT.helpMore),
|
|
3703
|
+
advanced: z.boolean().optional().describe(PARAM_TEXT.advanced),
|
|
3704
|
+
recommendedMax: z.number().int().positive().optional().describe(PARAM_TEXT.recommendedMax),
|
|
3705
|
+
openMode: z.enum(["inline", "drill", "popover", "dialog"], { error: OPEN_MODE_INVALID }).optional().describe("object/group/table props: how the value (or each row) opens"),
|
|
3638
3706
|
// ⚠ A STRIP SITE, exactly like OutField above: this is a `z.object`, so a key the model
|
|
3639
3707
|
// sends and this shape does not declare is dropped BEFORE the request leaves — a 200 with
|
|
3640
3708
|
// the declaration gone. `ui` rode through the backend's looseObject `config` and died here.
|
|
3641
3709
|
ui: propUiObject.optional().describe(
|
|
3642
|
-
"AUTHORING CHROME for this prop's control in the inspector \u2014 never a delivery effect, and the same object a FIELD's `ui` takes. `preview` and `layout` are valid on 'group', 'table' and 'slot'
|
|
3710
|
+
"AUTHORING CHROME for this prop's control in the inspector \u2014 never a delivery effect, and the same object a FIELD's `ui` takes. `preview` and `layout` are valid on 'group', 'table', 'object' and 'slot' (`layout` 'rows'|'chips'|'gallery' also on 'multiselect', 'tags'); `reorderable` on 'table' ALONE, the one prop type whose value is a LIST (a 'group' is one object, a 'slot' one component instance). preview keys name a SUB-FIELD of this prop's config.fields and are checked against them (on a 'slot' the shape is checked but the keys cannot be \u2014 they belong to whichever component fills it). An unknown key inside `ui` is a 400, not a silent strip. Read it back with get_component: a prop whose `ui` is absent there was not stored."
|
|
3643
3711
|
)
|
|
3644
3712
|
});
|
|
3645
3713
|
const getLayoutInput = z.object({
|
|
@@ -3661,9 +3729,9 @@ function buildToolDefs(deps) {
|
|
|
3661
3729
|
z.object({ type: z.literal("swap-component-variant"), ...sectionTarget, itemId: z.string().min(1), componentId: z.string().min(1) }),
|
|
3662
3730
|
z.object({ type: z.literal("set-page-state"), ...sectionTarget, state: z.enum(["inherit", "override-content", "customize-structure", "disable", "reset"]) }),
|
|
3663
3731
|
z.object({ type: z.literal("set-section-state"), ...sectionTarget, state: z.enum(["inherit", "override-content", "customize-structure", "disable", "reset"]) }),
|
|
3664
|
-
z.object({ type: z.literal("add-section"), name: z.string().min(1), icon: layoutIcon, zone: z.enum(["before_page", "after_page"]) }),
|
|
3732
|
+
z.object({ type: z.literal("add-section"), name: z.string().min(1), icon: layoutIcon, zone: z.enum(["before_page", "after_page"]), window: windowArg.optional() }),
|
|
3665
3733
|
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() }),
|
|
3666
|
-
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() }),
|
|
3734
|
+
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`") }),
|
|
3667
3735
|
z.object({ type: z.literal("remove-section"), ...sectionTarget }),
|
|
3668
3736
|
z.object({ type: z.literal("move-section"), ...sectionTarget, afterId: z.string().nullable().optional(), beforeId: z.string().nullable().optional() }),
|
|
3669
3737
|
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() }),
|
|
@@ -3712,12 +3780,13 @@ function buildToolDefs(deps) {
|
|
|
3712
3780
|
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."),
|
|
3713
3781
|
sectionType: sectionType.optional(),
|
|
3714
3782
|
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)"),
|
|
3783
|
+
from: z.string().min(1).max(255).optional().describe(PARAM_TEXT.fromBuiltin),
|
|
3715
3784
|
projectId: z.string().nullable().optional().describe("owning project id; null creates a workspace-wide global component"),
|
|
3716
3785
|
allowedOn: allowedOn.optional(),
|
|
3717
3786
|
description: z.string().optional().describe(PARAM_TEXT.describes),
|
|
3718
3787
|
blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
|
|
3719
3788
|
props: z.array(componentPropObject).default([]).describe(
|
|
3720
|
-
"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)."
|
|
3789
|
+
"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 + " " + PROP_V2_NOTE
|
|
3721
3790
|
)
|
|
3722
3791
|
});
|
|
3723
3792
|
const updateComponentInput = z.object({
|
|
@@ -4262,7 +4331,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
4262
4331
|
def(
|
|
4263
4332
|
"set_binding_mode",
|
|
4264
4333
|
"Set how the site's bindings are resolved",
|
|
4265
|
-
"Switch this project between the two binding resolvers, from the NEXT release on. `declaredBindings: true` makes the annotator trust the template's own data-bcms-field / data-bcms-props and never guess from rendered text \u2014 the durable state; `false` returns to text-matching, which
|
|
4334
|
+
"Switch this project between the two binding resolvers, from the NEXT release on. `declaredBindings: true` makes the annotator trust the template's own data-bcms-field / data-bcms-props and never guess from rendered text \u2014 the durable state; `false` returns to text-matching, which holds only while the CMS values equal the built copy. Call it ONLY after every page's copy is declared in the template: undeclared fields stop being editable. The order around the flip (push, release, clean get_binding_report, flip, release, confirm): skill bindings. Flipping back is the same call. REQUIRES artifact:write (the authority that deploys the site): this decides what every future release does to every page. On a workspace-wide connection pass `projectId`: the scope is there, but the human who authorized the connection must be able to publish THAT project or it is refused with 403 PROJECT_PUBLISH_DENIED. Playbook section 13.",
|
|
4266
4335
|
z.object({ declaredBindings: z.boolean().describe("true = trust the template's declared bindings; false = text-match (the default)") }).shape,
|
|
4267
4336
|
async (c, a) => ok("Recorded the binding mode.", await data(c, "PATCH", `/management/projects/current/binding-mode`, { declaredBindings: a.declaredBindings }))
|
|
4268
4337
|
),
|
|
@@ -4287,7 +4356,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
4287
4356
|
def(
|
|
4288
4357
|
"submit_conversion_receipt",
|
|
4289
4358
|
"Record what the conversion codemod could and could not do",
|
|
4290
|
-
"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`
|
|
4359
|
+
"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.",
|
|
4291
4360
|
z.object({
|
|
4292
4361
|
briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
|
|
4293
4362
|
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`.")
|
|
@@ -4397,7 +4466,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
4397
4466
|
def(
|
|
4398
4467
|
"update_page",
|
|
4399
4468
|
"Edit a page",
|
|
4400
|
-
"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)
|
|
4469
|
+
"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: skill components). A section change the live page cannot show comes back in `holds`: saved as a draft, not live until each `action` is taken (skill publishing). 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 that is refused, the page still goes live and `warnings` carries ENTRY_STILL_DRAFT (skill publishing). STRUCTURED DATA is native SEO, never a field: Automatic JSON-LD from the content, unless `schemaType` picks a schema.org type or pasted `schema` (wins over both, null clears it) is set (skill seo). " + PAGE_HEAD_NOTE + " " + SECTION_DOCTRINE,
|
|
4401
4470
|
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,
|
|
4402
4471
|
async (c, a) => {
|
|
4403
4472
|
const res = await c.fetchJSON(
|
|
@@ -4410,8 +4479,9 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
4410
4479
|
);
|
|
4411
4480
|
const note = res.redirectNote;
|
|
4412
4481
|
const summary = note ? `Updated page. An existing redirect from ${note.source} already points to ${note.existingDestination}; it wasn't changed, so the old address does not redirect to the new one.` : "Updated page.";
|
|
4413
|
-
|
|
4414
|
-
|
|
4482
|
+
const held = (res.holds ?? []).map((h) => `HELD ${h.reason}: ${h.message} Action: ${h.action.label}.`);
|
|
4483
|
+
const lines = [summary, ...res.warnings ?? [], ...held].join("\n");
|
|
4484
|
+
return ok(lines, res.holds?.length ? { ...res.data, holds: res.holds } : res.data);
|
|
4415
4485
|
}
|
|
4416
4486
|
),
|
|
4417
4487
|
// ── Code + deploy (parity with remote /mcp; needs artifact:write) ──
|
|
@@ -4427,7 +4497,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4427
4497
|
def(
|
|
4428
4498
|
"deploy_project",
|
|
4429
4499
|
"Deploy new source/build",
|
|
4430
|
-
"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` (
|
|
4500
|
+
"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.",
|
|
4431
4501
|
z.object({
|
|
4432
4502
|
data: z.string().min(1).optional().describe("base64 .tgz/.zip of the project; or send uploadKey"),
|
|
4433
4503
|
uploadKey: z.string().min(1).optional().describe("instead of data: the uploadKey from create_deploy_upload, after PUTting the archive to its uploadUrl"),
|
|
@@ -4558,6 +4628,10 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4558
4628
|
placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).nullable().optional(),
|
|
4559
4629
|
readOnly: z.union([z.literal(true), z.literal("developer")]).nullable().optional().describe(PARAM_TEXT.lockClears),
|
|
4560
4630
|
lockReason: z.string().max(200).nullable().optional().describe(PARAM_TEXT.lockClears),
|
|
4631
|
+
helpMore: z.string().max(2e3).nullable().optional().describe(PARAM_TEXT.helpMore),
|
|
4632
|
+
advanced: z.boolean().nullable().optional().describe(PARAM_TEXT.advanced),
|
|
4633
|
+
recommendedMax: z.number().int().positive().nullable().optional().describe(PARAM_TEXT.recommendedMax),
|
|
4634
|
+
openMode: z.enum(["inline", "drill", "popover", "dialog"], { error: OPEN_MODE_INVALID }).nullable().optional().describe(PARAM_TEXT.openMode),
|
|
4561
4635
|
type: fieldType.optional().describe("a new type; needs allowRetype"),
|
|
4562
4636
|
key: fieldKey.optional().describe("a new key (flat namespace, unique in the schema)"),
|
|
4563
4637
|
allowRetype: z.boolean().optional().describe("confirm a type change, once the user has agreed"),
|
|
@@ -4791,14 +4865,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4791
4865
|
def(
|
|
4792
4866
|
"get_binding_report",
|
|
4793
4867
|
"Check what on the live site is editable",
|
|
4794
|
-
"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
|
|
4795
|
-
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.
|
|
4868
|
+
"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.",
|
|
4869
|
+
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,
|
|
4796
4870
|
async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
|
|
4797
4871
|
),
|
|
4798
4872
|
def(
|
|
4799
4873
|
"get_conversion_brief",
|
|
4800
4874
|
"Get the brief for making this site's bindings durable",
|
|
4801
|
-
"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.
|
|
4875
|
+
"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.",
|
|
4802
4876
|
z.object({
|
|
4803
4877
|
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."),
|
|
4804
4878
|
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.")
|
|
@@ -4815,14 +4889,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4815
4889
|
def(
|
|
4816
4890
|
"get_site_composition",
|
|
4817
4891
|
"Check every page is assembled from registered components",
|
|
4818
|
-
"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
|
|
4892
|
+
"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.",
|
|
4819
4893
|
z.object({}).shape,
|
|
4820
4894
|
async (c) => ok("Site composition.", await data(c, "GET", `/management/projects/current/composition`))
|
|
4821
4895
|
),
|
|
4822
4896
|
def(
|
|
4823
4897
|
"get_componentize_plan",
|
|
4824
4898
|
"Get the plan for turning this site's sections into components",
|
|
4825
|
-
"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).
|
|
4899
|
+
"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.",
|
|
4826
4900
|
z.object({
|
|
4827
4901
|
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)."),
|
|
4828
4902
|
paged: z.boolean().optional().describe("true = 10 pages at a time; the response carries `page`, `pageCount` and a `cursor` for the next."),
|
|
@@ -4833,7 +4907,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4833
4907
|
def(
|
|
4834
4908
|
"componentize_sections",
|
|
4835
4909
|
"Turn this site's derived sections into components",
|
|
4836
|
-
"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
|
|
4910
|
+
"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).",
|
|
4837
4911
|
z.object({
|
|
4838
4912
|
digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
|
|
4839
4913
|
pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
|
|
@@ -5062,7 +5136,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
5062
5136
|
def(
|
|
5063
5137
|
"get_deploy_status",
|
|
5064
5138
|
"Get deploy/build status",
|
|
5065
|
-
"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
|
|
5139
|
+
"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`, `held` and `publishes` (each `live` or `partial`): 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.",
|
|
5066
5140
|
z.object({}).shape,
|
|
5067
5141
|
// Flat body, no `data` envelope: `data()` read it as undefined.
|
|
5068
5142
|
async (c) => ok("Deploy status.", await c.fetchJSON(c.url(`/management/projects/deploy-status`), { method: "GET" }))
|
|
@@ -5073,10 +5147,12 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
5073
5147
|
NEW_TOOL_TEXT.editor_setup,
|
|
5074
5148
|
z.object({
|
|
5075
5149
|
force: z.boolean().optional().describe(PARAM_TEXT.editorSetupForce),
|
|
5076
|
-
check: z.boolean().optional().describe(PARAM_TEXT.editorSetupCheck)
|
|
5150
|
+
check: z.boolean().optional().describe(PARAM_TEXT.editorSetupCheck),
|
|
5151
|
+
exempt: z.array(z.record(z.string(), z.unknown())).optional().describe(PARAM_TEXT.editorSetupExempt)
|
|
5077
5152
|
}).shape,
|
|
5078
5153
|
// The route answers `{ state, … }` with no `data` envelope, so the whole body is the result.
|
|
5079
|
-
|
|
5154
|
+
// `exempt` records setup-gate exceptions instead of running anything (the gate's one write).
|
|
5155
|
+
async (c, a) => a.exempt ? ok("Setup gate exceptions.", await c.fetchJSON(c.url(`/management/projects/setup-gate/exceptions`), { method: "POST", body: JSON.stringify({ exempt: a.exempt }) })) : ok("Editor setup.", await c.fetchJSON(c.url(`/management/projects/editor-setup`), a.check === true ? { method: "GET" } : { method: "POST", body: JSON.stringify({ force: a.force }) }))
|
|
5080
5156
|
),
|
|
5081
5157
|
// ── Deploy to the user's own host (parity with remote /mcp) ──
|
|
5082
5158
|
// A person connects Vercel or Netlify in the dashboard; nothing here touches OAuth or a host
|
|
@@ -5229,7 +5305,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5229
5305
|
name: "add_field",
|
|
5230
5306
|
config: {
|
|
5231
5307
|
title: "Add a field to a content model or a page",
|
|
5232
|
-
description: ADD_FIELD_DESCRIPTION + " " + RICH_TEXT_NOTE,
|
|
5308
|
+
description: ADD_FIELD_DESCRIPTION + " " + V2_LEAF_TYPES_NOTE + " " + RICH_TEXT_NOTE,
|
|
5233
5309
|
inputSchema: addFieldInput.shape
|
|
5234
5310
|
},
|
|
5235
5311
|
handler: guard(
|
|
@@ -5507,7 +5583,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5507
5583
|
name: "create_form",
|
|
5508
5584
|
config: {
|
|
5509
5585
|
title: "Create a form",
|
|
5510
|
-
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.",
|
|
5586
|
+
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.",
|
|
5511
5587
|
inputSchema: createFormInput.shape
|
|
5512
5588
|
},
|
|
5513
5589
|
handler: guard(
|
|
@@ -5566,7 +5642,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5566
5642
|
name: "update_layout",
|
|
5567
5643
|
config: {
|
|
5568
5644
|
title: "Apply Layout draft commands",
|
|
5569
|
-
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.",
|
|
5645
|
+
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,
|
|
5570
5646
|
inputSchema: commandLayoutInput.shape
|
|
5571
5647
|
},
|
|
5572
5648
|
handler: guard(async (args) => withClient(async (client) => {
|
|
@@ -5604,7 +5680,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5604
5680
|
name: "publish_layout",
|
|
5605
5681
|
config: {
|
|
5606
5682
|
title: "Publish the Global Layout draft",
|
|
5607
|
-
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 (
|
|
5683
|
+
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.",
|
|
5608
5684
|
inputSchema: publishLayoutInput.shape
|
|
5609
5685
|
},
|
|
5610
5686
|
handler: guard(async (args) => withClient(async (client) => {
|
|
@@ -5760,7 +5836,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5760
5836
|
client.url(`/management/pages/${pageId}/meta`),
|
|
5761
5837
|
{ method: "PATCH", body: JSON.stringify({ blockJson: page.blockJson, ...args.dryRun ? { dryRun: true } : {} }) }
|
|
5762
5838
|
);
|
|
5763
|
-
results.push({ ok: true, ...args.dryRun ? { status: "would-update" } : {}, pageId, slug: written.data?.slug ?? page.slug, path: written.data?.path, blocks: page.blockJson.length });
|
|
5839
|
+
results.push({ ok: true, ...args.dryRun ? { status: "would-update" } : {}, pageId, slug: written.data?.slug ?? page.slug, path: written.data?.path, blocks: page.blockJson.length, ...written.holds?.length ? { holds: written.holds } : {} });
|
|
5764
5840
|
} catch (err) {
|
|
5765
5841
|
results.push({ ok: false, pageId, slug: page.slug, error: errorText(err) });
|
|
5766
5842
|
}
|
|
@@ -5942,7 +6018,7 @@ ${lines.join("\n")}`, found);
|
|
|
5942
6018
|
name: "publish_component",
|
|
5943
6019
|
config: {
|
|
5944
6020
|
title: "Publish a component",
|
|
5945
|
-
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
|
|
6021
|
+
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.",
|
|
5946
6022
|
inputSchema: getComponentInput.shape
|
|
5947
6023
|
},
|
|
5948
6024
|
handler: guard(
|
|
@@ -6078,6 +6154,9 @@ DERIVED by \`editor_setup\`, where the componentize lane in \xA710 reuses every
|
|
|
6078
6154
|
A call to action (words + link)? -> ONE \`button\` field {label, href, variant?, size?,
|
|
6079
6155
|
arrow?, icon?, newTab?} \u2014 never text + link fields
|
|
6080
6156
|
(in a component: a \`button\` block, \xA73)
|
|
6157
|
+
A link to a page or CMS item? -> a \`link\` value {type:'page'|'entry', href, ref:<id>}:
|
|
6158
|
+
the write checks ref, delivery resolves it;
|
|
6159
|
+
a plain href string stays valid
|
|
6081
6160
|
Site-wide nav/footer/modal? -> the LAYOUT (get_layout/update_layout), never a
|
|
6082
6161
|
page section and never one component per page
|
|
6083
6162
|
|
|
@@ -6228,7 +6307,7 @@ is interpolated per entry with \`{{fieldKey}}\` placeholders \u2014 \`{{title}}\
|
|
|
6228
6307
|
6. content create_content_entry / set_page_content
|
|
6229
6308
|
7. publish update_page status:'published'
|
|
6230
6309
|
7b. layout publish_layout <- if you authored the Global Layout (nav/footer); page overrides publish with the page
|
|
6231
|
-
8. check get_next_steps, and fix what it lists
|
|
6310
|
+
8. check get_next_steps, and fix what it lists; on an imported site, until receipt.complete is true
|
|
6232
6311
|
|
|
6233
6312
|
## 8. Two ways to ship a blank site
|
|
6234
6313
|
|
|
@@ -6404,7 +6483,8 @@ the release's characters. Ornaments, skip links and the platform badge are exclu
|
|
|
6404
6483
|
4. deploy again and re-read. Repeat until every route is under 2%.
|
|
6405
6484
|
|
|
6406
6485
|
\`get_next_steps\` runs the same test and hands you the worst route with its buckets
|
|
6407
|
-
(\`unaddressable-copy\`).
|
|
6486
|
+
(\`unaddressable-copy\`). Its \`receipt.gate\` holds EVERY built route to that bar, beside its page,
|
|
6487
|
+
its images and its links, and \`receipt.complete\` stays false until each route passes. A \`coverage\` carrying \`error: "nothing-bound"\` means NOT ONE element
|
|
6408
6488
|
of the build is bound \u2014 usually the singleton lane declined the project (\`skipReasons\`
|
|
6409
6489
|
\`authored-content\`: its content models were authored by the repo), so the percentage beside it
|
|
6410
6490
|
describes a site this build is not.
|
|
@@ -6539,9 +6619,12 @@ below is one heading of that ask.
|
|
|
6539
6619
|
the \`add-component\` command. CTA buttons are \`button\` BLOCKS inside the section that uses them,
|
|
6540
6620
|
exposed as \`text\` + \`url\` props that target that ONE block \u2014 never the words as a separate text
|
|
6541
6621
|
block beside it, or the Button panel edits nothing \u2014 variant, size and fullWidth are that block's own props and
|
|
6542
|
-
\`style\`. Do not hand-build a Button component
|
|
6543
|
-
family (Globals/Buttons) on its own, and a new look is \`create_component\` with
|
|
6544
|
-
Button. On
|
|
6622
|
+
\`style\`. Do not hand-build a Button component. On a site built here every button look you write joins
|
|
6623
|
+
the project's Button family (Globals/Buttons) on its own, and a new look is \`create_component\` with
|
|
6624
|
+
\`variantOf\` naming a Button. On an IMPORTED site the family is the looks measured in the site and
|
|
6625
|
+
nothing you write adds to it: read it with \`list_components\`, place a look as a \`component\` block,
|
|
6626
|
+
and never add CSS for a look the site does not have. On a page's FIELDS a CTA is one \`button\` field
|
|
6627
|
+
(\xA72), whose \`variant\` picks a look of that family. Rich text is a \`richtext\` prop over a
|
|
6545
6628
|
\`richtext\`/\`text\` block. Forms are \`create_form\` plus a \`form\` block, and form FIELDS are the
|
|
6546
6629
|
one place the platform models \`required\`, \`placeholder\`, \`helpText\` and real \`validation\`
|
|
6547
6630
|
(emailPolicy, min/max, phoneFormat, pattern) \u2014 put a site's validated inputs there. Cards,
|
|
@@ -6869,6 +6952,15 @@ conversion is yours to write.
|
|
|
6869
6952
|
fetch the live URL cache-busted (RECEIPTS in \xA711: publish and deploy are separate claims).
|
|
6870
6953
|
\`get_next_steps\` keeps reporting the gap until every one of these holds.
|
|
6871
6954
|
|
|
6955
|
+
**The finish line is \`receipt.complete\`.** \`get_next_steps\` answers it from the setup gate
|
|
6956
|
+
(\`receipt.gate\`): every route of the live build has a published page (or entry), one page only,
|
|
6957
|
+
at most 2% of its visible text unowned, every image a field's and 95% of its links. \`blocking\`
|
|
6958
|
+
lists what fails, worst route first, each with the \`fix\` call to run; a null \`fix\` is the
|
|
6959
|
+
user's to decide. A gap left on purpose is RECORDED, \`editor_setup { exempt: [{ route, check,
|
|
6960
|
+
reason, note }] }\`, with a reason \`allowedExceptions\` lists for that check. Running out of time,
|
|
6961
|
+
context or patience is in \`neverExceptions\`: it is refused, and telling the user why instead
|
|
6962
|
+
leaves the gate incomplete. \`bettercms init\` reads the same gate when you exit and prints it.
|
|
6963
|
+
|
|
6872
6964
|
**Done means.** Whichever recipe you ran: \`get_binding_report\` reads \`mode "declared"\`,
|
|
6873
6965
|
\`unmatched\` empty, \`bound\` above 0, \`coverage.pending\` empty, and \`canvas.lane\` is \`bridge\` or
|
|
6874
6966
|
\`draft-route\` when the site should show structural drafts.
|
|
@@ -6958,7 +7050,7 @@ FIELD never takes \`ui.group\`; it joins a fieldset with \`fieldsetId\`.
|
|
|
6958
7050
|
- **leaf** \u2014 one field, one control. Nothing to declare; \`ui.control\` only when the default is wrong.
|
|
6959
7051
|
- **group** \u2014 a \`kind: 'group'\` fieldset (a prop: \`ui.group\`): a disclosure that shows two values
|
|
6960
7052
|
when closed. \`collapsed: true\` starts it closed.
|
|
6961
|
-
- **row** \u2014 a \`kind: 'row'\` fieldset: at most 3 short scalars (number, color, boolean, date,
|
|
7053
|
+
- **row** \u2014 a \`kind: 'row'\` fieldset: at most 3 short scalars (number, rating, color, boolean, date,
|
|
6962
7054
|
datetime, select) on one line. No \`ui.control\` on a row member.
|
|
6963
7055
|
- **branch** \u2014 a nesting field drawn as a drill-in row with its \`ui.icon\` and a summary.
|
|
6964
7056
|
- **list** \u2014 repeatable items named by \`ui.itemLabel\` and \`ui.preview\`.
|
|
@@ -6991,6 +7083,32 @@ Importance orders a screen: required fields first, then array order.
|
|
|
6991
7083
|
| CTA | one \`kind:'group'\` fieldset holding the heading, text and ONE \`button\` field, \`icon:'launch'\` |
|
|
6992
7084
|
| Dimensions | one \`kind:'row'\` fieldset of up to 3 numbers (width, height, gap) |
|
|
6993
7085
|
|
|
7086
|
+
**The V2 schema language \u2014 to get X in the editor, write Y.** Every key below is editor-only and stored as sent, EXCEPT \`type: 'object'\`: it changes the stored and delivered value to \`{ [childKey]: value }\` (a \`group\` carries \`<key>.nonRepeatable.<sub>\` instead), and codegen types it as a nested object, so swapping group \u2194 object moves values and templates.
|
|
7087
|
+
|
|
7088
|
+
| To get this in the editor | Write this |
|
|
7089
|
+
|---|---|
|
|
7090
|
+
| A section that starts closed / a static heading | fieldset \`collapsed: true\` / \`collapsible: false\` |
|
|
7091
|
+
| Two short fields side by side | fieldset \`columns: 2\` |
|
|
7092
|
+
| One Desktop \xB7 Tablet \xB7 Phone switcher | three \`type: 'object'\` fields named \`desktop\`, \`tablet\`, \`phone\`, each with \`fieldsetId\` pointing at ONE fieldset with \`layout: 'breakpoints'\` |
|
|
7093
|
+
| A small inset of 2\u20133 fields, or a row that opens its own screen | \`type: 'object'\` with \`fields\`; \`openMode: 'inline', 'drill', 'popover', 'dialog'\` |
|
|
7094
|
+
| List rows that drill in instead of expanding | \`openMode: 'drill'\` on the array (or repeater) |
|
|
7095
|
+
| Tiles instead of rows | \`ui.layout: 'grid'\` on a list of objects (V2 \`arrayLayout: 'grid'\`); a primitive image array takes \`'gallery'\` |
|
|
7096
|
+
| BetterCMS-only list layouts | \`ui.layout: 'rows'\` (\u2261 'list'), \`'chips'\`, \`'gallery'\`, also on a primitive array. Never chips for a feature list: chips cut long lines |
|
|
7097
|
+
| A size capsule with numeric steps | \`type: 'number'\`, \`config.scale: { steps: {min, max, step} or [{value, label}], units?, presets? }\`. Token (string) steps and an \`allowCustom\` object are refused (\`scale_invalid\`): use numeric steps and \`allowCustom: true\` |
|
|
7098
|
+
| Help under the label / longer help behind \u24D8 | \`helpText\` / \`helpMore\` |
|
|
7099
|
+
| Calm defaults for rare settings | \`advanced: true\` |
|
|
7100
|
+
| A soft length warning (amber, never a refusal) | \`recommendedMax: 60\` |
|
|
7101
|
+
| A marketer cannot change it | \`readOnly: 'developer'\` plus \`lockReason\` |
|
|
7102
|
+
| A field that shows only sometimes | \`showIf\` (it SHOWS the field): ops \`eq\`, \`neq\`, \`in\`, \`isSet\`, \`notEmpty\`, \`empty\` |
|
|
7103
|
+
| A good row title and thumbnail | \`ui.preview: { title, subtitle, media, fallback }\` (\`fallback\` is a literal label) |
|
|
7104
|
+
| Labels that classify or filter (topics, keywords), never a feature list | \`type: 'tags'\`: a string[], trimmed, de-duplicated, each \u226464 characters; \`config.options\` are suggestions, \`config.max\`, \`config.allowCustom: false\` closes the list (\`tags_value_invalid\`) |
|
|
7105
|
+
| A star rating | \`type: 'rating'\`: a number from 0 to \`config.max\` (5), \`config.step\` 1 or 0.5 (\`rating_value_invalid\`) |
|
|
7106
|
+
| A code snippet | \`type: 'code'\`: \`{ language?, code }\` or a string; \`config.language\` default, \`config.languages\` allowlist (\`code_value_invalid\`) |
|
|
7107
|
+
| "Add question" instead of "Add item" | \`ui.itemLabel: 'Question'\` (V2 \`itemNoun\`; there is no itemNoun key) |
|
|
7108
|
+
| The same section as a component (the dock edits a page section through its props) | the same keys on its \`props\`: \`type: 'object'\` with \`config.fields\` (\`group\` and \`table\` too, 10 levels), \`datetime\`, \`slug\`, \`email\`, \`phone\`, \`json\`, \`location\`, \`tags\`, \`rating\`, \`code\`, plus the two CMS-item types (\`reference\`, \`multi-reference\`: \`config.contentModelId\` or \`polymorphic\` + \`allowedModelSlugs\`; page reads hydrate them, a text slot shows the title), and every key above except fieldsets (props group with \`ui.group\`) |
|
|
7109
|
+
|
|
7110
|
+
An unknown \`openMode\` is refused with \`openMode_invalid\`, one on a leaf \`openMode_leaf\`; a bad \`config.scale\` with \`scale_invalid\`; more than 10 nested field levels with \`field_depth_exceeded\`. update_field sets and clears (null) helpMore, advanced, recommendedMax and openMode on an existing field.
|
|
7111
|
+
|
|
6994
7112
|
**Tabs.** A \`kind: 'tab'\` fieldset is a tab:
|
|
6995
7113
|
- at most **3** tabs, names at most 20 characters and unique;
|
|
6996
7114
|
- top-level fields only: tabs never nest and props never take them;
|
|
@@ -7213,7 +7331,7 @@ Author a form (then the user embeds it with \`<BcmsForm form={getForm('Name')} /
|
|
|
7213
7331
|
1. **Discover** \u2014 \`list_forms\` to see existing forms; \`get_form\` to read one before editing.
|
|
7214
7332
|
2. **Collect fields (loop)** \u2014 for each: key (machine key for the value), label, type. Types:
|
|
7215
7333
|
text, email, textarea, select (needs \`options: string[]\`), checkbox, checkboxes (needs
|
|
7216
|
-
\`options\`), radio (needs \`options\`), number, phone, date, url, consent, hidden. Optional per
|
|
7334
|
+
\`options\`), radio (needs \`options\`), number, phone, date, url, consent, hidden, step (divider). Optional per
|
|
7217
7335
|
field: required?, placeholder?, helpText? (shown under the input), defaultValue?,
|
|
7218
7336
|
\`validation\` (\`emailPolicy\` on email, \`min\`/\`max\` on number, \`phoneFormat\` on phone,
|
|
7219
7337
|
\`pattern\` on text/textarea/url \u2014 a rule on any other type is a 400),
|
|
@@ -7239,9 +7357,9 @@ error-prone, so go slow and confirm. Never guess the layout.
|
|
|
7239
7357
|
the same prop keys across a family or a swap drops content.
|
|
7240
7358
|
4. **Props** \u2014 declare only what should really be editable:
|
|
7241
7359
|
{ key, label, target: { blockId, path },
|
|
7242
|
-
type: text|richtext|image|url|boolean|number|select|group|table|slot|form|link|button|date|color|file|icon|multiselect }.
|
|
7243
|
-
'select'/'multiselect' need config.options; 'group'/'table' need config.fields; a 'button'
|
|
7244
|
-
is ONE prop on a button block's 'props', never a label prop plus a url prop; 'slot' holds ONE nested component;
|
|
7360
|
+
type: text|richtext|image|url|boolean|number|select|group|table|slot|form|link|button|date|color|file|icon|multiselect|object|datetime|slug|email|phone|json|location|tags|rating|code|reference|multi-reference }.
|
|
7361
|
+
'select'/'multiselect' need config.options; 'group'/'table'/'object' need config.fields; a 'button'
|
|
7362
|
+
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);
|
|
7245
7363
|
'form' holds ONE Form document \u2014 point target.path at 'props' on an empty form block and set
|
|
7246
7364
|
defaultValue to {formId}. Never re-declare a form's FIELDS as props: the Form document owns
|
|
7247
7365
|
them, and a second copy is a second source of truth that drifts on the first edit.
|
|
@@ -7699,8 +7817,10 @@ Read bettercms://playbook/schema section 11 first, then work this order:
|
|
|
7699
7817
|
scale, nav position/background, footer surface as DTCG {"$type","$value"} entries) and
|
|
7700
7818
|
redeploy, or structural draft previews render in a generic theme, not this site's design.
|
|
7701
7819
|
5. VERIFY \u2014 call get_next_steps and fix what it lists (it knows about missing manifests,
|
|
7702
|
-
a setup that has not run, and placed-but-unpublished components)
|
|
7703
|
-
|
|
7820
|
+
a setup that has not run, and placed-but-unpublished components). You are finished only
|
|
7821
|
+
when its receipt.complete is true: work receipt.gate.blocking top down, and record an item
|
|
7822
|
+
you cannot close with editor_setup { exempt } under an allowed reason. Telling me why is
|
|
7823
|
+
not an exception.
|
|
7704
7824
|
|
|
7705
7825
|
On 401/403, the MCP key needs (re)authorizing.`
|
|
7706
7826
|
}
|