@bettercms-ai/mcp 0.68.2 → 0.69.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 +129 -45
- package/dist/index.js.map +1 -1
- package/dist/skills/bettercms/SKILL.md +8 -4
- package/dist/skills/bettercms/references/accessibility.md +171 -0
- package/dist/skills/bettercms/references/components.md +20 -4
- package/dist/skills/bettercms/references/content-modeling.md +153 -24
- package/dist/skills/bettercms/references/detection.md +450 -0
- package/dist/skills/bettercms/references/media-and-brand.md +2 -1
- package/dist/skills/bettercms/references/publishing.md +7 -4
- package/dist/skills/bettercms/references/seo.md +226 -26
- package/dist/skills/bettercms/references/site-blueprints.md +633 -2
- package/dist/skills/bettercms/references/site-review.md +2 -1
- package/dist/skills/bettercms/references/worked-examples-advanced.md +495 -0
- package/dist/skills/bettercms/references/worked-examples.md +725 -0
- package/dist/skills/bettercms/references/write-limits.md +138 -4
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -27,7 +27,16 @@ var COMPONENT_PROP_TYPES = [
|
|
|
27
27
|
"group",
|
|
28
28
|
"table",
|
|
29
29
|
"slot",
|
|
30
|
-
"form"
|
|
30
|
+
"form",
|
|
31
|
+
// The seven below each store what the same-named content-model FIELD stores (`icon` and
|
|
32
|
+
// `multiselect` have no field twin; see the value notes on ComponentPropDef.type).
|
|
33
|
+
"link",
|
|
34
|
+
"button",
|
|
35
|
+
"date",
|
|
36
|
+
"color",
|
|
37
|
+
"file",
|
|
38
|
+
"icon",
|
|
39
|
+
"multiselect"
|
|
31
40
|
];
|
|
32
41
|
|
|
33
42
|
// ../types/src/layout-lucide-icons.ts
|
|
@@ -2510,7 +2519,7 @@ function allKeys(fields) {
|
|
|
2510
2519
|
})
|
|
2511
2520
|
]);
|
|
2512
2521
|
}
|
|
2513
|
-
var CLEARABLE = ["helpText", "showIf", "ui", "placement"];
|
|
2522
|
+
var CLEARABLE = ["helpText", "showIf", "ui", "placement", "readOnly", "lockReason"];
|
|
2514
2523
|
var SETTABLE = ["label", "required", ...CLEARABLE];
|
|
2515
2524
|
function mergeConfig(current, patch) {
|
|
2516
2525
|
const out = { ...current ?? {} };
|
|
@@ -2548,7 +2557,7 @@ function planFieldEdit(model, edit, callerIfMatch) {
|
|
|
2548
2557
|
}
|
|
2549
2558
|
const changes = SETTABLE.filter((k) => edit[k] !== void 0);
|
|
2550
2559
|
if (!retype && rename === void 0 && edit.config === void 0 && changes.length === 0) {
|
|
2551
|
-
return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, type or key." };
|
|
2560
|
+
return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, readOnly, lockReason, type or key." };
|
|
2552
2561
|
}
|
|
2553
2562
|
const echo = helpTextEcho(edit.helpText, { label: edit.label ?? current.label, defaultValue: current.defaultValue });
|
|
2554
2563
|
if (echo) return { ok: false, error: echo };
|
|
@@ -2601,6 +2610,7 @@ var LISTING_TAIL = /* @__PURE__ */ new Set([
|
|
|
2601
2610
|
"extract_component",
|
|
2602
2611
|
"list_extraction_candidates",
|
|
2603
2612
|
"create_deploy_upload",
|
|
2613
|
+
"import_redirects",
|
|
2604
2614
|
"set_authoring_preference",
|
|
2605
2615
|
"set_media_delivery",
|
|
2606
2616
|
"delete_form_submission",
|
|
@@ -2679,7 +2689,7 @@ var NEW_TOOL_TEXT = {
|
|
|
2679
2689
|
discard_draft: "Throw away unpublished edits to an entry (`entryId`) or a page (`pageId`) \u2014 exactly one: the draft returns to exactly what is live and `pendingChanges` becomes false. A page resets its blockJson and its published Layout overrides. 409 no_published_version means it was never published (delete it instead). Pass ifMatch (optimisticVersion) to refuse if it changed since you read it.",
|
|
2680
2690
|
give_feedback: "Tell the BetterCMS team about a tool that failed, a capability you needed and did not have, or docs that misled you. Opens a ticket for the team; nothing in the project changes. Name the `tool`, and paste the request and response into `context`. Never include secrets or personal data.",
|
|
2681
2691
|
publish_entries: "Publish or unpublish many entries and pages in ONE call (up to 100 ids of each; pages publish only). Needs a publish-enabled connection, and each item still passes its own gates (editorial stage, required fields). Nothing stops the run: the result lists what changed (`published` or `unpublished`) and every refusal in `failed` with its reason. A publish answers 202 with the routes being rebuilt.",
|
|
2682
|
-
create_content_entries: "Create up to 200 DRAFT entries of one collection in ONE call, each {slug?, data} as create_content_entry takes it. Every row reports `created` (with id and slug) or `failed` (with the error), a failure does not stop the others, and `meta` counts
|
|
2692
|
+
create_content_entries: "Create up to 200 DRAFT entries of one collection in ONE call, each {slug?, data} as create_content_entry takes it. Every row reports `created` (with id and slug) or `failed` (with the error), a failure does not stop the others, and `meta` counts them. A row with `externalKey` is safe to send again: its entry is `updated` (draft only), or `unchanged` when the row is the same. A row setting a developer-locked field fails (FIELD_LOCKED). Publish afterwards with publish_entries.",
|
|
2683
2693
|
translate_entry: "Create an entry's `targetLocale` translation with AI (uses AI credits: 402/403 when out or not on the plan). Text and longtext fields are translated; rich text and documents are copied for you to translate; the rest is copied. The new entry is a DRAFT with `locale` set and `translationOf` = the source; `meta` lists translated and copied keys. `targetLocale` must be one of the project's locales; 409 locale_conflict means that translation exists, so edit it instead.",
|
|
2684
2694
|
list_webhooks: "List the project's outgoing webhooks: name, url, events, whether active, and delivery health (total, failed, last attempt). Signing secrets are never returned.",
|
|
2685
2695
|
create_webhook: "Create an outgoing webhook: BetterCMS POSTs each subscribed event to your https `url`, signed with HMAC-SHA256 in X-BetterCMS-Signature. The response carries the signing `secret` ONCE: store it now. `test: true` also sends a sample delivery. Payloads carry ids and metadata, never the content: fetch it from the API. A receiver that writes back on content.updated loops unless it skips its own writes.",
|
|
@@ -2689,11 +2699,21 @@ var NEW_TOOL_TEXT = {
|
|
|
2689
2699
|
search_docs: "Search the BetterCMS documentation. Returns the best pages (title, url, breadcrumbs, snippet). Use it before guessing how a feature works; `meta.unavailable` means docs search is down right now.",
|
|
2690
2700
|
generate_image: "Generate an image with AI and add it to the Media Library (5 AI credits; free with the workspace's own Together key). `width`/`height` are 256-1440 in multiples of 16 (default 1024x768). Returns the new asset (id, url, altText): use the url as an 'image' field value. Always set `altText`.",
|
|
2691
2701
|
refresh_search_index: "Tell search engines and site search about published changes; costs nothing. action 'indexnow' submits `urls` (this site's own, up to 1000) to IndexNow, or every change since the last submission when omitted. action 'reindex' rebuilds the project's site-search index. 503 means the index is unreachable: try again later.",
|
|
2702
|
+
import_redirects: "Bulk-create redirects for a migration, from `rows` or from `text` in `format` (our CSV export, a Netlify `_redirects` file, or a vercel.json). Call it with `dryRun: true` FIRST: nothing is written and each row comes back `would-create`, `existing` or `skipped` with a reason. Rows get create_redirect's rules. Max 5000.",
|
|
2692
2703
|
editor_setup: "Make the live build editable. A build imports only SEO/meta, media, brand and the models bcms-content.json declares; this derives the rest from the live release: pages, collections, forms, bindings, button and section components (when the authoring preference is components), Component Groups and the Content sidebar. Run it after an import or a rebuild, before the conversion brief. Idempotent: `ready` when it already ran for the live sha, else it starts (`running`; poll with `check: true` or get_deploy_status `editorSetup`). `state`: never or stale (run it), running, ready, failed. 409 = nothing is live yet: deploy first. Then get_next_steps.",
|
|
2693
2704
|
transform_image: "Edit an existing Media Library image with an instruction (e.g. 'remove the background', 'make it dusk'); the result is saved as a NEW asset and the original is kept (5 AI credits). Returns the new asset (id, url, altText)."
|
|
2694
2705
|
};
|
|
2695
2706
|
var DELETE_PAGE_RUNTIME_BUNDLE = " Or, with no pageId, `runtimeBundle`: 'report' lists the duplicate pages derived from a server app's own bundle (`app/` folders); 'apply' retires them and their empty folders.";
|
|
2707
|
+
var FIELD_LOCKED_NOTE = "A readOnly 'developer' field keeps its stored value: changing or omitting it without schema access is 403 FIELD_LOCKED.";
|
|
2696
2708
|
var PARAM_TEXT = {
|
|
2709
|
+
dryRun: "validates and reports what would be written; writes nothing",
|
|
2710
|
+
externalKey: "id in the source system; sent again, it updates that entry's draft or leaves it unchanged",
|
|
2711
|
+
upsertSource: "source URL, kept with the entry",
|
|
2712
|
+
upsertRunId: "your run's id, kept with the entry",
|
|
2713
|
+
externalKeyFilter: "exact externalKey an entry was created with",
|
|
2714
|
+
readOnly: "'developer' = the value and this lock change only with schema access (needs lockReason); true = an editor marker",
|
|
2715
|
+
lockReason: "which system owns the value (\u2264 200 chars)",
|
|
2716
|
+
lockClears: "as on add_field; null clears it",
|
|
2697
2717
|
helpText: "shown under the field: its purpose, where it shows, any constraint; never its label or value. Omitted: written for you.",
|
|
2698
2718
|
describes: "for editors: what it is and holds; omitted: written for you",
|
|
2699
2719
|
runtimeBundle: "instead of pageId: the server-app bundle cleanup",
|
|
@@ -2719,14 +2739,14 @@ var PARAM_TEXT = {
|
|
|
2719
2739
|
confirmedInput: "true only after the user explicitly approved adding this custom input (`ui.input`); else setting or changing one is refused input_needs_confirmation. Removing one needs no approval"
|
|
2720
2740
|
};
|
|
2721
2741
|
var INPUT_CONFIRMED_HEADER = "x-bcms-confirmed-input";
|
|
2722
|
-
var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant?, size?, arrow?, icon?, newTab?} (arrow:false, icon:null turn one off): never a text field beside a link field.";
|
|
2723
|
-
var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, ui or
|
|
2742
|
+
var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant?, size?, arrow?, icon?, newTab?, rel?} (arrow:false, icon:null turn one off): never a text field beside a link field.";
|
|
2743
|
+
var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, ui, placement, readOnly or lockReason (null clears those). The rest of the schema is written back unchanged at the version read; a 409 means it changed since, so read again. `type` changes how every stored value is read and needs allowRetype: true. `key` renames it: stored values stay under the old key, so the route answers 409 with the entry count unless allowFieldRemoval: true discards them \u2014 migrate the values first. Ask the user before a retype or a rename. A page's label + link pair (primaryCtaText + primaryCtaHref) becomes one button with `mergeHref`.";
|
|
2724
2744
|
var GET_SEO_DESCRIPTION = "Get the connected project's site-wide SEO settings \u2014 every key update_seo writes: seoDefaults (metaTitle, metaDescription, ogImage, twitterHandle, og, twitter, collectionSchemaTypes), siteSchema (the site's JSON-LD), robotsConfig, sitemapConfig, rssConfig, indexnowConfig and searchConfig.";
|
|
2725
2745
|
var UPDATE_SEO_DESCRIPTION = "Set the connected project's site-wide SEO defaults and config. Each key you send is REPLACED whole (not deep-merged), so read get_seo and send the complete object. `seoDefaults` applies to every page that does not override it, and its `collectionSchemaTypes` maps a collection's model id to the schema.org type of its entries. `siteSchema` is the site's own JSON-LD (Organization/WebSite, emitted on the home page). `robotsConfig`, `sitemapConfig` and `rssConfig` shape robots.txt, sitemap.xml and the feed; `indexnowConfig` pings search engines on publish; `searchConfig` is site search. A key the plan does not include answers 403 FEATURE_LOCKED. Rebuilds the site.";
|
|
2726
2746
|
var SEO_KEY_TEXT = {
|
|
2727
2747
|
seoDefaults: "site-wide meta defaults every page inherits",
|
|
2728
2748
|
siteSchema: "the site's JSON-LD: an object or an array of objects",
|
|
2729
|
-
robotsConfig: "robots.txt: 'auto', or 'custom' with its text",
|
|
2749
|
+
robotsConfig: "robots.txt: 'auto' (`disallow` names AI crawlers to block), or 'custom' with its text",
|
|
2730
2750
|
sitemapConfig: "sitemap.xml: generated ('auto') or 'custom', defaults and extra URLs",
|
|
2731
2751
|
rssConfig: "the RSS feed on or off",
|
|
2732
2752
|
indexnowConfig: "IndexNow on publish, minus URL patterns to leave out",
|
|
@@ -2753,7 +2773,7 @@ function whereToQuery(where) {
|
|
|
2753
2773
|
}
|
|
2754
2774
|
function entryListSearch(args) {
|
|
2755
2775
|
const q = new URLSearchParams();
|
|
2756
|
-
for (const key of ["modelId", "pageId", "status"]) if (args[key]) q.set(key, String(args[key]));
|
|
2776
|
+
for (const key of ["modelId", "pageId", "status", "externalKey"]) if (args[key]) q.set(key, String(args[key]));
|
|
2757
2777
|
for (const [k, v] of whereToQuery(args.where)) q.append(k, v);
|
|
2758
2778
|
for (const key of ["sort", "page", "limit"]) if (args[key] !== void 0 && args[key] !== null) q.set(key, String(args[key]));
|
|
2759
2779
|
if (args.detail !== true) q.set("select", "summary");
|
|
@@ -2954,7 +2974,7 @@ var fieldShape = {
|
|
|
2954
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'})."
|
|
2955
2975
|
),
|
|
2956
2976
|
config: z.record(z.string(), z.unknown()).optional().describe(
|
|
2957
|
-
"per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model; richtext {inline:true, hostTag:'h2', accept:['h2','p','b','i','u','link'], features:['bold','italic','underline','link','headings'], allowMultipleParagraphs:false} \u2014 for a field bound to ONE element on the page: `hostTag` is that element's tag and `accept` must name it, so the editor's block-style control opens already showing 'Heading 2' instead of 'Paragraph'. Name exactly ONE heading level: publish refuses an <h3> value bound to an <h2> rather than nesting one heading inside the other. Keep 'p' in `accept` or the sanitiser strips a block demoted to Paragraph"
|
|
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"
|
|
2958
2978
|
),
|
|
2959
2979
|
fields: z.array(z.lazy(() => fieldObject)).optional().describe(
|
|
2960
2980
|
"NESTED child fields \u2014 REQUIRED for type 'group' (one nested object, a Non-Repeatable Zone like blog_hero \u2192 heading, description, hero_image) and type 'repeater' (a repeatable array of such objects, a Repeatable Zone / section-list like testimonials \u2192 quote, author). A section with repeating items is a 'repeater'; a fixed grouped block is a 'group'. Recurse to any depth \u2014 do NOT flatten zones into separate top-level fields."
|
|
@@ -2970,8 +2990,8 @@ var fieldShape = {
|
|
|
2970
2990
|
rules: z.array(
|
|
2971
2991
|
z.object({
|
|
2972
2992
|
field: z.string().describe("the BARE KEY of another field on this same model"),
|
|
2973
|
-
op: z.enum(["eq", "neq", "in"]),
|
|
2974
|
-
value: z.union([z.string(), z.array(z.string())])
|
|
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")
|
|
2975
2995
|
})
|
|
2976
2996
|
)
|
|
2977
2997
|
}).optional().describe(
|
|
@@ -2991,7 +3011,9 @@ var fieldShape = {
|
|
|
2991
3011
|
searchable: z.boolean().optional().describe("omit (\u2261 true) to index this field for site search; false excludes it"),
|
|
2992
3012
|
unique: z.boolean().optional().describe(
|
|
2993
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 number. A clash is refused with 409 `unique_conflict`."
|
|
2994
|
-
)
|
|
3014
|
+
),
|
|
3015
|
+
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)
|
|
2995
3017
|
};
|
|
2996
3018
|
var fieldObject = z.object(fieldShape);
|
|
2997
3019
|
var urlPatternArg = z.string().nullable().optional().describe(
|
|
@@ -3016,7 +3038,7 @@ var fieldsetsArg = z.array(
|
|
|
3016
3038
|
);
|
|
3017
3039
|
var dropProvenance = (sets) => sets?.map(({ uiOrigin: _origin, uiBefore: _before, ...set }) => set) ?? sets;
|
|
3018
3040
|
var RICH_TEXT_NOTE = "\u{1F534} A field of type 'text' is created as RICH TEXT unless you send `richText: false` on it. Send `richText: false` for every value that is not prose: a name used as a title, a URL, slug, id, SKU, email, phone, CSS class or icon name.";
|
|
3019
|
-
var SCHEMA_ORG_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"];
|
|
3041
|
+
var SCHEMA_ORG_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness", "JobPosting", "Course", "Recipe", "HowTo", "Service", "Person", "VideoObject"];
|
|
3020
3042
|
var changefreq = z.enum(["auto", "always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]);
|
|
3021
3043
|
var jsonObject = z.record(z.string(), z.unknown());
|
|
3022
3044
|
var seoPatchShape = {
|
|
@@ -3030,7 +3052,7 @@ var seoPatchShape = {
|
|
|
3030
3052
|
collectionSchemaTypes: z.record(z.string().min(1).max(64), z.enum(SCHEMA_ORG_TYPES)).optional()
|
|
3031
3053
|
}).optional().describe(SEO_KEY_TEXT.seoDefaults),
|
|
3032
3054
|
siteSchema: z.union([jsonObject, z.array(jsonObject)]).optional().describe(SEO_KEY_TEXT.siteSchema),
|
|
3033
|
-
robotsConfig: z.object({ mode: z.enum(["auto", "custom"]), custom: z.string().max(1e4).optional() }).optional().describe(SEO_KEY_TEXT.robotsConfig),
|
|
3055
|
+
robotsConfig: z.object({ mode: z.enum(["auto", "custom"]), custom: z.string().max(1e4).optional(), disallow: z.array(z.enum(["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "anthropic-ai", "PerplexityBot", "Google-Extended", "CCBot", "Bytespider", "Applebot-Extended", "Amazonbot", "Meta-ExternalAgent"])).max(32).optional() }).optional().describe(SEO_KEY_TEXT.robotsConfig),
|
|
3034
3056
|
sitemapConfig: z.object({
|
|
3035
3057
|
enabled: z.boolean(),
|
|
3036
3058
|
mode: z.enum(["auto", "custom"]).optional(),
|
|
@@ -3079,6 +3101,8 @@ function proseType(f) {
|
|
|
3079
3101
|
}
|
|
3080
3102
|
var confirmedHeaders = (confirmed) => confirmed === true ? { [INPUT_CONFIRMED_HEADER]: "true" } : {};
|
|
3081
3103
|
var withHeaders = (headers) => Object.keys(headers).length > 0 ? { headers } : {};
|
|
3104
|
+
var DRY_RUN_SUMMARY = "Dry run: nothing was written.";
|
|
3105
|
+
var dryRunOf = async (client, method, path, body, headers = {}) => ok(DRY_RUN_SUMMARY, await client.fetchJSON(client.url(path), { method, body: JSON.stringify({ ...body, dryRun: true }), ...withHeaders(headers) }));
|
|
3082
3106
|
function toField(f) {
|
|
3083
3107
|
const base = {
|
|
3084
3108
|
key: f.key,
|
|
@@ -3093,7 +3117,10 @@ function toField(f) {
|
|
|
3093
3117
|
...f.defaultValue !== void 0 ? { defaultValue: f.defaultValue } : {},
|
|
3094
3118
|
...f.searchable !== void 0 ? { searchable: f.searchable } : {},
|
|
3095
3119
|
// BE-6: a leaf key like the four above, so it rides on `base` and survives every branch.
|
|
3096
|
-
...f.unique !== void 0 ? { unique: f.unique } : {}
|
|
3120
|
+
...f.unique !== void 0 ? { unique: f.unique } : {},
|
|
3121
|
+
// The developer lock: stripped here, an agent writing a model back would unlock every field.
|
|
3122
|
+
...f.readOnly !== void 0 ? { readOnly: f.readOnly } : {},
|
|
3123
|
+
...f.lockReason !== void 0 ? { lockReason: f.lockReason } : {}
|
|
3097
3124
|
};
|
|
3098
3125
|
if (f.type === "group") {
|
|
3099
3126
|
return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
|
|
@@ -3338,7 +3365,7 @@ function buildToolDefs(deps) {
|
|
|
3338
3365
|
]).describe("block type; section/slider/tabs/columns nest child blocks"),
|
|
3339
3366
|
id: z.string().min(1).describe("stable unique block id"),
|
|
3340
3367
|
props: z.record(z.string(), z.unknown()).describe(
|
|
3341
|
-
"per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href, variant? primary|secondary|tertiary|brand|dark|light|outline|link, size? sm|md|lg, fullWidth?, arrow? boolean, icon? ui.icon name, newTab? boolean}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may hold ANY block; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug
|
|
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? primary|secondary|tertiary|brand|dark|light|outline|link, size? sm|md|lg, fullWidth?, arrow? boolean, icon? ui.icon name, newTab? boolean, rel? nofollow|sponsored|ugc}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may hold ANY block; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?, source? {modelId}, filters? [{field, op, value}], sort?, pinnedIds?, excludeCurrent?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>; with `source`, that model's entries as cards on any page"
|
|
3342
3369
|
),
|
|
3343
3370
|
style: z.record(z.string(), z.unknown()).optional().describe(
|
|
3344
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."
|
|
@@ -3391,7 +3418,8 @@ function buildToolDefs(deps) {
|
|
|
3391
3418
|
fields: z.array(fieldObject).optional().describe(
|
|
3392
3419
|
"the model's typed schema fields. 'group'/'repeater' NEST their child fields (any depth) \u2014 don't flatten zones into top-level fields."
|
|
3393
3420
|
),
|
|
3394
|
-
fieldsets: fieldsetsArg
|
|
3421
|
+
fieldsets: fieldsetsArg,
|
|
3422
|
+
dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
|
|
3395
3423
|
});
|
|
3396
3424
|
const afterArg = z.string().min(1).optional().describe(
|
|
3397
3425
|
"OPTIONAL insertion point: the key of an existing TOP-LEVEL field to insert this one directly after. Omit to append at the end (the default, and the behaviour before this existed). Use it to put a field where an author expects it instead of at the bottom \u2014 e.g. after:'hero_title' for a subtitle. An unknown key is refused rather than silently appended."
|
|
@@ -3399,6 +3427,7 @@ function buildToolDefs(deps) {
|
|
|
3399
3427
|
const addFieldInput = z.object({
|
|
3400
3428
|
modelId: z.string().min(1).optional().describe("the content model to extend (from list_content_models); or send pageId"),
|
|
3401
3429
|
pageId: z.string().min(1).optional().describe("instead of modelId: the page whose schema to extend (from list_pages / create_page)"),
|
|
3430
|
+
dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun),
|
|
3402
3431
|
...fieldShape,
|
|
3403
3432
|
after: afterArg,
|
|
3404
3433
|
confirmed: z.boolean().optional().describe(PARAM_TEXT.confirmedInput)
|
|
@@ -3413,13 +3442,20 @@ function buildToolDefs(deps) {
|
|
|
3413
3442
|
transcript: z.string().max(1e5).optional().describe("video/audio only: the spoken words (\u2264 100,000 chars), published wherever the file is used"),
|
|
3414
3443
|
folderId: z.string().optional().describe("target Media Library folder (defaults to project root)")
|
|
3415
3444
|
});
|
|
3445
|
+
const upsertShape = {
|
|
3446
|
+
externalKey: z.string().min(1).max(255).optional().describe(PARAM_TEXT.externalKey),
|
|
3447
|
+
source: z.string().max(2048).optional().describe(PARAM_TEXT.upsertSource),
|
|
3448
|
+
runId: z.string().max(100).optional().describe(PARAM_TEXT.upsertRunId)
|
|
3449
|
+
};
|
|
3416
3450
|
const createEntryInput = z.object({
|
|
3417
3451
|
contentModelId: z.string().min(1).describe("id of the model this entry belongs to"),
|
|
3418
3452
|
slug: slug.optional(),
|
|
3419
3453
|
status: z.enum(["draft", "published"]).optional().describe("defaults to draft"),
|
|
3420
3454
|
data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key"),
|
|
3421
3455
|
locale: z.string().nullable().optional().describe(PARAM_TEXT.locale),
|
|
3422
|
-
translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf)
|
|
3456
|
+
translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf),
|
|
3457
|
+
dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun),
|
|
3458
|
+
...upsertShape
|
|
3423
3459
|
});
|
|
3424
3460
|
const getPageInput = z.object({
|
|
3425
3461
|
pageId: z.string().min(1).describe(PARAM_TEXT.pageRef)
|
|
@@ -3439,6 +3475,7 @@ function buildToolDefs(deps) {
|
|
|
3439
3475
|
modelId: z.string().optional().describe("filter by content model id"),
|
|
3440
3476
|
pageId: z.string().optional().describe("filter by page id (a singleton page has one entry)"),
|
|
3441
3477
|
status: z.enum(["draft", "published"]).optional(),
|
|
3478
|
+
externalKey: z.string().optional().describe(PARAM_TEXT.externalKeyFilter),
|
|
3442
3479
|
where: z.record(z.string(), z.unknown()).optional().describe(PARAM_TEXT.where),
|
|
3443
3480
|
sort: z.string().max(100).optional().describe(PARAM_TEXT.sort),
|
|
3444
3481
|
page: z.number().int().min(1).optional().describe(PARAM_TEXT.page),
|
|
@@ -3456,7 +3493,7 @@ function buildToolDefs(deps) {
|
|
|
3456
3493
|
metaDescription: clearableText(500),
|
|
3457
3494
|
noindex: z.boolean().optional().describe("hide this entry from search engines and site search"),
|
|
3458
3495
|
...headPatchShape,
|
|
3459
|
-
schemaType: z.enum(
|
|
3496
|
+
schemaType: z.enum(SCHEMA_ORG_TYPES).nullable().optional(),
|
|
3460
3497
|
schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional()
|
|
3461
3498
|
});
|
|
3462
3499
|
const updateEntryInput = z.object({
|
|
@@ -3587,6 +3624,17 @@ function buildToolDefs(deps) {
|
|
|
3587
3624
|
required: z.boolean().optional().describe("mark the field required in the editor. An editor HINT: the API still accepts a placement that leaves it empty, and get_site_composition is what reports one."),
|
|
3588
3625
|
helpText: z.string().max(200).optional().describe("hint shown under the control, muted (\u2264 200 chars)"),
|
|
3589
3626
|
placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)"),
|
|
3627
|
+
// Stored markers on a prop: nothing refuses a prop value for them. Declared so an echo keeps them.
|
|
3628
|
+
readOnly: z.union([z.literal(true), z.literal("developer")]).optional(),
|
|
3629
|
+
lockReason: z.string().max(200).optional(),
|
|
3630
|
+
showIf: z.object({
|
|
3631
|
+
match: z.enum(["all", "any"]),
|
|
3632
|
+
rules: z.array(z.object({
|
|
3633
|
+
field: z.string().min(1),
|
|
3634
|
+
op: z.enum(["eq", "neq", "in", "isSet"]),
|
|
3635
|
+
value: z.union([z.string(), z.array(z.string())]).optional()
|
|
3636
|
+
})).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' takes no value. Editor-only."),
|
|
3590
3638
|
// ⚠ A STRIP SITE, exactly like OutField above: this is a `z.object`, so a key the model
|
|
3591
3639
|
// sends and this shape does not declare is dropped BEFORE the request leaves — a 200 with
|
|
3592
3640
|
// the declaration gone. `ui` rode through the backend's looseObject `config` and died here.
|
|
@@ -3669,7 +3717,7 @@ function buildToolDefs(deps) {
|
|
|
3669
3717
|
description: z.string().optional().describe(PARAM_TEXT.describes),
|
|
3670
3718
|
blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
|
|
3671
3719
|
props: z.array(componentPropObject).default([]).describe(
|
|
3672
|
-
"overridable fields. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a prop targets are click-to-edit. Each one may carry `ui` to say how its control presents in the inspector \u2014 a collapsed group, what titles and thumbnails a table's rows, whether that table's rows can be dragged at all."
|
|
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)."
|
|
3673
3721
|
)
|
|
3674
3722
|
});
|
|
3675
3723
|
const updateComponentInput = z.object({
|
|
@@ -3729,7 +3777,8 @@ function buildToolDefs(deps) {
|
|
|
3729
3777
|
slug: z.string().min(1).optional().describe("a page slug, when one page has it; or pass `pageId`"),
|
|
3730
3778
|
blockJson: z.array(blockObject).describe("REPLACES this page's whole block composition")
|
|
3731
3779
|
})
|
|
3732
|
-
).min(1).max(50)
|
|
3780
|
+
).min(1).max(50),
|
|
3781
|
+
dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
|
|
3733
3782
|
});
|
|
3734
3783
|
const listComponentsInput = z.object({
|
|
3735
3784
|
detail: z.boolean().optional().describe("true = every component's full blockJson and props. Costly and rarely what you want \u2014 read one definition with get_component instead.")
|
|
@@ -3795,16 +3844,17 @@ function buildToolDefs(deps) {
|
|
|
3795
3844
|
def(
|
|
3796
3845
|
"update_media",
|
|
3797
3846
|
"Update a media asset",
|
|
3798
|
-
"Describe an existing media asset: set its alt text, caption, transcript or focal point (focalX/focalY, the spot crops keep in frame; delivered images carry it with width, height and thumbHash). A transcript (video/audio only, \u2264 100,000 chars) is stored ON the asset, so every page and entry using the file publishes it \u2014 hosted pages, the .md twin, llms-full.txt and the delivery API. Pass null to clear a field. Use this after your own speech-to-text; BetterCMS does not generate transcripts.",
|
|
3847
|
+
"Describe an existing media asset: set its alt text, caption, transcript, `decorative` (no alt text needed) or focal point (focalX/focalY, the spot crops keep in frame; delivered images carry it with width, height and thumbHash). A transcript (video/audio only, \u2264 100,000 chars) is stored ON the asset, so every page and entry using the file publishes it \u2014 hosted pages, the .md twin, llms-full.txt and the delivery API. Pass null to clear a field. Use this after your own speech-to-text; BetterCMS does not generate transcripts.",
|
|
3799
3848
|
z.object({
|
|
3800
3849
|
assetId: z.string().min(1).describe("media asset id (from list_media / upload_asset)"),
|
|
3801
3850
|
altText: z.string().max(500).nullable().optional().describe("alt text; null clears"),
|
|
3802
3851
|
caption: z.string().max(1e3).nullable().optional().describe("caption; null clears"),
|
|
3803
3852
|
transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only."),
|
|
3853
|
+
decorative: z.boolean().optional().describe("needs no alt text"),
|
|
3804
3854
|
focalX: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalX),
|
|
3805
3855
|
focalY: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalY)
|
|
3806
3856
|
}).shape,
|
|
3807
|
-
async (c, a) => ok("Updated media asset.", await data(c, "PATCH", `/management/media/${encodeURIComponent(s(a.assetId))}`, { altText: a.altText, caption: a.caption, transcript: a.transcript, focalX: a.focalX, focalY: a.focalY }))
|
|
3857
|
+
async (c, a) => ok("Updated media asset.", await data(c, "PATCH", `/management/media/${encodeURIComponent(s(a.assetId))}`, { altText: a.altText, caption: a.caption, transcript: a.transcript, decorative: a.decorative, focalX: a.focalX, focalY: a.focalY }))
|
|
3808
3858
|
),
|
|
3809
3859
|
def(
|
|
3810
3860
|
"list_media",
|
|
@@ -3994,15 +4044,27 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3994
4044
|
"update_redirect",
|
|
3995
4045
|
"Update a redirect",
|
|
3996
4046
|
"Update an existing redirect in place \u2014 change where it points, its HTTP status, or disable it without deleting. Prefer this over delete+create: it keeps the redirect's id and preserves the chain-collapse rewrites of other rules pointing at it. Only provided fields change. Loops 422, duplicate sources 409.",
|
|
3997
|
-
z.object({ redirectId: z.string().min(1).describe("redirect id (from list_redirects)"), sourcePath: z.string().min(1).optional(), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308"]).optional(), isActive: z.boolean().optional().describe("set false to disable without deleting") }).shape,
|
|
4047
|
+
z.object({ redirectId: z.string().min(1).describe("redirect id (from list_redirects)"), sourcePath: z.string().min(1).optional(), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308", "410"]).optional().describe("HTTP status; 410 = Gone"), isActive: z.boolean().optional().describe("set false to disable without deleting") }).shape,
|
|
3998
4048
|
async (c, a) => ok("Updated redirect.", await data(c, "PATCH", `/management/redirects/${s(a.redirectId)}`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType, isActive: a.isActive }))
|
|
3999
4049
|
),
|
|
4000
4050
|
def(
|
|
4001
4051
|
"create_redirect",
|
|
4002
4052
|
"Create a redirect",
|
|
4003
4053
|
"Create a URL redirect \u2014 e.g. a 301 after renaming a page's slug. `sourcePath` is the path to redirect FROM ('/old-page'), `destination` the path/url TO ('/new-page'). Chains collapse; loops/dupes rejected. Defaults to 301.",
|
|
4004
|
-
z.object({ sourcePath: z.string().min(1), destination: z.string().min(1), redirectType: z.enum(["301", "302", "307", "308"]).optional() }).shape,
|
|
4005
|
-
async (c, a) => ok("Created redirect.", await data(c, "POST", `/management/redirects`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType }))
|
|
4054
|
+
z.object({ sourcePath: z.string().min(1), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308", "410"]).optional().describe("default '301'; 410 = Gone, omit destination"), dryRun: z.boolean().optional().describe("true = return the verdict, write nothing") }).shape,
|
|
4055
|
+
async (c, a) => ok(a.dryRun === true ? "Dry run: nothing written." : "Created redirect.", await data(c, "POST", `/management/redirects`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType, dryRun: a.dryRun }))
|
|
4056
|
+
),
|
|
4057
|
+
def(
|
|
4058
|
+
"import_redirects",
|
|
4059
|
+
"Import redirects",
|
|
4060
|
+
NEW_TOOL_TEXT.import_redirects,
|
|
4061
|
+
z.object({
|
|
4062
|
+
rows: z.array(z.record(z.string(), z.unknown())).optional().describe("[{ sourcePath, destination?, redirectType? }]"),
|
|
4063
|
+
text: z.string().optional().describe("the file's contents, instead of rows"),
|
|
4064
|
+
format: z.enum(["csv", "_redirects", "vercel"]).optional().describe("of `text` (default 'csv')"),
|
|
4065
|
+
dryRun: z.boolean().optional().describe("default false")
|
|
4066
|
+
}).shape,
|
|
4067
|
+
async (c, a) => ok(a.dryRun === true ? "Dry run: nothing written." : "Imported redirects.", await data(c, "POST", `/management/redirects/import`, { rows: a.rows, text: a.text, format: a.format, dryRun: a.dryRun }))
|
|
4006
4068
|
),
|
|
4007
4069
|
def(
|
|
4008
4070
|
"delete_redirect",
|
|
@@ -4336,7 +4398,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
4336
4398
|
"update_page",
|
|
4337
4399
|
"Edit a page",
|
|
4338
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) naming the overrides the new component does not render; they stay on the block, so swapping back restores them. It does NOT change the field SCHEMA \u2014 use add_field (pageId) / set_page_content for that. Renaming the slug keeps content intact. Publishing copies the draft blocks live in the same call \u2014 and on a singleton it publishes the page's content entry too; if the entry's workflow stage refuses, the page still goes live and `warnings` carries ENTRY_STILL_DRAFT with the entry id and the reason. STRUCTURED DATA is native SEO, never a field: leave it out and the page gets Automatic JSON-LD from its content (WebSite on the home page, Blog/CollectionPage on a collection's list page, WebPage otherwise); `schemaType` picks an explicit schema.org type whose properties are filled from the content; `schema` is pasted JSON-LD and wins over both (null clears it). " + PAGE_HEAD_NOTE + " " + SECTION_DOCTRINE,
|
|
4339
|
-
z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1).describe(PARAM_TEXT.pageRef), title: z.string().optional(), slug: z.string().optional(), blockJson: z.array(blockObject).optional().describe("REPLACES the page's block composition"), metaTitle: z.string().optional(), metaDescription: z.string().optional(), schemaType: z.enum(["auto",
|
|
4401
|
+
z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1).describe(PARAM_TEXT.pageRef), title: z.string().optional(), slug: z.string().optional(), blockJson: z.array(blockObject).optional().describe("REPLACES the page's block composition"), metaTitle: z.string().optional(), metaDescription: z.string().optional(), schemaType: z.enum(["auto", ...SCHEMA_ORG_TYPES]).optional().describe("structured data type; 'auto' (the default) derives it from the content"), schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional().describe("custom JSON-LD; wins over schemaType; null clears it"), status: z.enum(["draft", "published"]).optional(), scheduledAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageScheduledAt), unpublishAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageUnpublishAt), locale: z.string().nullable().optional().describe(PARAM_TEXT.locale), translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf), ifMatch: z.number().int().min(0).optional().describe(UPDATE_PAGE_IF_MATCH_NOTE), dynamic: z.object({ previewPath: z.string().nullable().optional().describe(PARAM_TEXT.previewPath), modelId: z.string().min(1).optional().describe(PARAM_TEXT.pageModelId) }).optional().describe(PARAM_TEXT.pageDynamic) }).shape,
|
|
4340
4402
|
async (c, a) => {
|
|
4341
4403
|
const res = await c.fetchJSON(
|
|
4342
4404
|
c.url(`/management/pages/${s(a.pageId)}/meta`),
|
|
@@ -4494,6 +4556,8 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4494
4556
|
showIf: z.record(z.string(), z.unknown()).nullable().optional().describe("same shape as on add_field; null clears it"),
|
|
4495
4557
|
ui: z.record(z.string(), z.unknown()).nullable().optional().describe("same vocabulary as on add_field; null clears it"),
|
|
4496
4558
|
placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).nullable().optional(),
|
|
4559
|
+
readOnly: z.union([z.literal(true), z.literal("developer")]).nullable().optional().describe(PARAM_TEXT.lockClears),
|
|
4560
|
+
lockReason: z.string().max(200).nullable().optional().describe(PARAM_TEXT.lockClears),
|
|
4497
4561
|
type: fieldType.optional().describe("a new type; needs allowRetype"),
|
|
4498
4562
|
key: fieldKey.optional().describe("a new key (flat namespace, unique in the schema)"),
|
|
4499
4563
|
allowRetype: z.boolean().optional().describe("confirm a type change, once the user has agreed"),
|
|
@@ -4604,10 +4668,11 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4604
4668
|
NEW_TOOL_TEXT.create_content_entries,
|
|
4605
4669
|
z.object({
|
|
4606
4670
|
contentModelId: z.string().min(1).describe("the collection every entry belongs to (from list_content_models)"),
|
|
4607
|
-
entries: z.array(z.object({ slug: z.string().optional(), data: z.record(z.string(), z.unknown()) })).min(1).max(200).describe("1-200 entries, each {slug?, data}")
|
|
4671
|
+
entries: z.array(z.object({ slug: z.string().optional(), data: z.record(z.string(), z.unknown()), ...upsertShape })).min(1).max(200).describe("1-200 entries, each {slug?, data}"),
|
|
4672
|
+
dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
|
|
4608
4673
|
}).shape,
|
|
4609
4674
|
// The whole body: the per-row receipt is `data`, the counts ride in `meta`.
|
|
4610
|
-
async (c, a) => ok("Batch receipt.", await c.fetchJSON(c.url(`/management/content/entries/batch`), { method: "POST", body: JSON.stringify({ contentModelId: a.contentModelId, entries: a.entries }) }))
|
|
4675
|
+
async (c, a) => ok(a.dryRun ? DRY_RUN_SUMMARY : "Batch receipt.", await c.fetchJSON(c.url(`/management/content/entries/batch`), { method: "POST", body: JSON.stringify({ contentModelId: a.contentModelId, entries: a.entries, dryRun: a.dryRun }) }))
|
|
4611
4676
|
),
|
|
4612
4677
|
def(
|
|
4613
4678
|
"translate_entry",
|
|
@@ -4800,7 +4865,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4800
4865
|
def(
|
|
4801
4866
|
"list_seo_issues",
|
|
4802
4867
|
"List SEO issues",
|
|
4803
|
-
"Scan every published page for SEO problems (missing or over-long titles and descriptions, missing OG image, noindex,
|
|
4868
|
+
"Scan every published page for SEO problems (missing or over-long titles and descriptions, missing OG image, noindex, invalid pasted schema) and return them with severities. Also reports accessibility issues as `a11y.*` codes with block ids. Computed live \u2014 nothing is stored.",
|
|
4804
4869
|
z.object({}).shape,
|
|
4805
4870
|
async (c) => ok("SEO issues.", await data(c, "GET", `/management/insights/seo-issues`))
|
|
4806
4871
|
),
|
|
@@ -5142,7 +5207,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5142
5207
|
async (args) => withClient(async (client) => {
|
|
5143
5208
|
const modelDupes = duplicateKeys(flatFieldKeys(args.fields));
|
|
5144
5209
|
if (modelDupes.length > 0) return fail(duplicateKeyFailure(modelDupes));
|
|
5145
|
-
const
|
|
5210
|
+
const input = {
|
|
5146
5211
|
name: args.name,
|
|
5147
5212
|
slug: args.slug,
|
|
5148
5213
|
...args.description !== void 0 ? { description: args.description } : {},
|
|
@@ -5150,7 +5215,9 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5150
5215
|
...args.kind !== void 0 ? { kind: args.kind } : {},
|
|
5151
5216
|
...args.fieldsets !== void 0 ? { fieldsets: dropProvenance(args.fieldsets) } : {},
|
|
5152
5217
|
fields: toFields(args.fields)
|
|
5153
|
-
}
|
|
5218
|
+
};
|
|
5219
|
+
if (args.dryRun) return dryRunOf(client, "POST", `/management/content/models`, input);
|
|
5220
|
+
const model = await client.createModel(input);
|
|
5154
5221
|
return ok(
|
|
5155
5222
|
`Created content model '${model.name}' (${model.fields.length} field(s)).`,
|
|
5156
5223
|
model
|
|
@@ -5168,6 +5235,15 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5168
5235
|
handler: guard(
|
|
5169
5236
|
async (args) => withClient(async (client) => {
|
|
5170
5237
|
if (!args.modelId === !args.pageId) return fail("Pass exactly one of modelId (a content model) or pageId (a page).");
|
|
5238
|
+
if (args.dryRun) {
|
|
5239
|
+
return dryRunOf(
|
|
5240
|
+
client,
|
|
5241
|
+
args.pageId ? "PATCH" : "POST",
|
|
5242
|
+
args.pageId ? `/management/pages/${args.pageId}` : `/management/content/models/${args.modelId}/fields`,
|
|
5243
|
+
{ addFields: [toField(args)], ...args.after !== void 0 ? { after: args.after } : {} },
|
|
5244
|
+
confirmedHeaders(args.confirmed)
|
|
5245
|
+
);
|
|
5246
|
+
}
|
|
5171
5247
|
if (args.pageId) {
|
|
5172
5248
|
const input2 = {
|
|
5173
5249
|
// Cast: the SDK types `options` as string[], narrower than the API, which also takes
|
|
@@ -5224,19 +5300,23 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5224
5300
|
},
|
|
5225
5301
|
handler: guard(
|
|
5226
5302
|
async (args) => withClient(async (client) => {
|
|
5227
|
-
const
|
|
5303
|
+
const input = {
|
|
5228
5304
|
contentModelId: args.contentModelId,
|
|
5229
5305
|
...args.slug !== void 0 ? { slug: args.slug } : {},
|
|
5230
5306
|
...args.data !== void 0 ? { data: args.data } : {},
|
|
5231
5307
|
// The SDK's input type predates localization; its body is the input as given.
|
|
5232
5308
|
...args.locale !== void 0 ? { locale: args.locale } : {},
|
|
5233
|
-
...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {}
|
|
5234
|
-
|
|
5309
|
+
...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {},
|
|
5310
|
+
...args.externalKey !== void 0 ? { externalKey: args.externalKey, source: args.source, runId: args.runId } : {}
|
|
5311
|
+
};
|
|
5312
|
+
if (args.dryRun) return dryRunOf(client, "POST", `/management/content/entries`, input);
|
|
5313
|
+
const created = await client.createEntry(input);
|
|
5235
5314
|
const published = args.status !== void 0 && args.status !== "draft" ? await client.updateEntry(created.id, { status: args.status }) : created;
|
|
5236
5315
|
const { warnings, ...entry } = published;
|
|
5237
5316
|
const notes = [.../* @__PURE__ */ new Set([...created.warnings ?? [], ...warnings ?? []])];
|
|
5238
5317
|
return ok(
|
|
5239
|
-
|
|
5318
|
+
// With an external key the write may have updated the entry, or left it unchanged.
|
|
5319
|
+
[`${args.externalKey ? "Stored" : "Created"} entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, ...notes].join("\n"),
|
|
5240
5320
|
entry
|
|
5241
5321
|
);
|
|
5242
5322
|
})
|
|
@@ -5299,7 +5379,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5299
5379
|
name: "update_content_entry",
|
|
5300
5380
|
config: {
|
|
5301
5381
|
title: "Update a content entry's values",
|
|
5302
|
-
description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list; an image value may carry this placement's focal {x,y} and crop {top,right,bottom,left} (0\u20131). Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, pass merge:true, or read the entry (get_content_entry), change that key and send the whole object back. " + ENTRY_META_NOTE,
|
|
5382
|
+
description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list; an image value may carry this placement's focal {x,y} and crop {top,right,bottom,left} (0\u20131). Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, pass merge:true, or read the entry (get_content_entry), change that key and send the whole object back. " + FIELD_LOCKED_NOTE + " " + ENTRY_META_NOTE,
|
|
5303
5383
|
inputSchema: updateEntryInput.shape
|
|
5304
5384
|
},
|
|
5305
5385
|
handler: guard(
|
|
@@ -5678,14 +5758,17 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5678
5758
|
try {
|
|
5679
5759
|
const written = await client.fetchJSON(
|
|
5680
5760
|
client.url(`/management/pages/${pageId}/meta`),
|
|
5681
|
-
{ method: "PATCH", body: JSON.stringify({ blockJson: page.blockJson }) }
|
|
5761
|
+
{ method: "PATCH", body: JSON.stringify({ blockJson: page.blockJson, ...args.dryRun ? { dryRun: true } : {} }) }
|
|
5682
5762
|
);
|
|
5683
|
-
results.push({ ok: true, pageId, slug: written.data?.slug ?? page.slug, path: written.data?.path, blocks: page.blockJson.length });
|
|
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 });
|
|
5684
5764
|
} catch (err) {
|
|
5685
5765
|
results.push({ ok: false, pageId, slug: page.slug, error: errorText(err) });
|
|
5686
5766
|
}
|
|
5687
5767
|
}
|
|
5688
5768
|
const failed = results.filter((r) => !r.ok).length;
|
|
5769
|
+
if (args.dryRun) {
|
|
5770
|
+
return ok(DRY_RUN_SUMMARY, { dryRun: true, results, wouldCompose: results.length - failed, failed });
|
|
5771
|
+
}
|
|
5689
5772
|
return ok(
|
|
5690
5773
|
`Composed ${results.length - failed} of ${args.pages.length} page(s)${failed ? `; ${failed} failed \u2014 see the per-item errors` : ""}. These are page DRAFTS: publish each with update_page status:'published'. Then call get_site_composition for the receipt.`,
|
|
5691
5774
|
results
|
|
@@ -6801,13 +6884,13 @@ delivered value \u2014 they change editor chrome only.
|
|
|
6801
6884
|
{ "key": "testimonials", "label": "Testimonials", "type": "repeater",
|
|
6802
6885
|
"ui": { "collapsed": true, "preview": { "title": "author", "media": "avatar" },
|
|
6803
6886
|
"layout": "grid", "icon": "double-quote", "itemLabel": "Testimonial" },
|
|
6804
|
-
"helpText": "Three to five.
|
|
6887
|
+
"helpText": "Three to five.",
|
|
6805
6888
|
"fields": [ { "key": "author", ... }, { "key": "avatar", "type": "image", ... }, ... ] }
|
|
6806
6889
|
\`\`\`
|
|
6807
6890
|
|
|
6808
|
-
- **\`helpText\`** \u2014
|
|
6809
|
-
|
|
6810
|
-
"The title field"
|
|
6891
|
+
- **\`helpText\`** \u2014 the exception: most fields need none. Write it only when the author needs it to
|
|
6892
|
+
type the right value: a format or unit ("Price in cents."), a placement the canvas cannot show, a
|
|
6893
|
+
real constraint, a fallback. Never restate the label ("The title field"), never coach ("Keep it short").
|
|
6811
6894
|
- **\`showIf\`** \u2014 hide a field until another has a value:
|
|
6812
6895
|
\`{match:'all',rules:[{field:'link_type',op:'eq',value:'external'}]}\`. Rules name fields by
|
|
6813
6896
|
BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
|
|
@@ -7156,8 +7239,9 @@ error-prone, so go slow and confirm. Never guess the layout.
|
|
|
7156
7239
|
the same prop keys across a family or a swap drops content.
|
|
7157
7240
|
4. **Props** \u2014 declare only what should really be editable:
|
|
7158
7241
|
{ key, label, target: { blockId, path },
|
|
7159
|
-
type: text|richtext|image|url|boolean|number|select|group|table|slot|form }.
|
|
7160
|
-
config.options; 'group'/'table' need config.fields; '
|
|
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;
|
|
7161
7245
|
'form' holds ONE Form document \u2014 point target.path at 'props' on an empty form block and set
|
|
7162
7246
|
defaultValue to {formId}. Never re-declare a form's FIELDS as props: the Form document owns
|
|
7163
7247
|
them, and a second copy is a second source of truth that drifts on the first edit.
|