@bettercms-ai/mcp 0.84.0 → 0.84.3
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/README.md
CHANGED
|
@@ -1,25 +1,20 @@
|
|
|
1
1
|
# @bettercms-ai/mcp
|
|
2
2
|
|
|
3
|
-
BetterCMS [Model Context Protocol](https://modelcontextprotocol.io) server
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
the OAuth 2.0 Device Authorization Grant (RFC 8628) — no pasted long-lived key.
|
|
3
|
+
BetterCMS [Model Context Protocol](https://modelcontextprotocol.io) server, as a local stdio
|
|
4
|
+
process. It signs in with the OAuth 2.0 Device Authorization Grant (RFC 8628): no pasted
|
|
5
|
+
long-lived key.
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
| `create_model` | Create a content model (schema) with optional initial fields. |
|
|
13
|
-
| `add_field` | Append a field to an existing model (read-modify-write; never removes fields). |
|
|
14
|
-
| `create_entry` | Create a content entry; applies `data`/`status` via a follow-up update. |
|
|
15
|
-
| `create_form` / `update_form` | Author a form (fields + settings) to embed with `<BcmsForm>`. |
|
|
16
|
-
| `create_component` / `update_component` | Author a reusable component (blockJson tree + props) to render with `<BcmsBlocks>`. |
|
|
17
|
-
| `list_forms` / `get_form` / `list_components` / `get_component` | Discover forms & components to wire into site code. |
|
|
7
|
+
> Most people should use the **hosted** server instead: add `https://mcp.bettercms.ai/mcp` to
|
|
8
|
+
> your client, or run `npx @bettercms-ai/install --mcp`. This package exposes the same tools
|
|
9
|
+
> for clients that only speak stdio. Setup per client and the full tool reference:
|
|
10
|
+
> https://bettercms.ai/docs/ai/connect-mcp and https://bettercms.ai/docs/ai/mcp-tools
|
|
18
11
|
|
|
19
|
-
|
|
12
|
+
## Tools
|
|
20
13
|
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
The same tool set as the hosted server: pages and sections, content models and fields, entries,
|
|
15
|
+
components, media, forms, SEO, publishing and deploys, and more. Writes stay drafts unless you
|
|
16
|
+
allowed publishing when you approved the client. Agents learn the order of work from the
|
|
17
|
+
`bettercms` skill (`npx @bettercms-ai/install`).
|
|
23
18
|
|
|
24
19
|
## Usage
|
|
25
20
|
|
|
@@ -30,28 +25,12 @@ Add to your MCP client config (e.g. Claude Desktop / Claude Code):
|
|
|
30
25
|
"mcpServers": {
|
|
31
26
|
"bettercms": {
|
|
32
27
|
"command": "npx",
|
|
33
|
-
"args": ["-y", "@bettercms-ai/mcp"]
|
|
34
|
-
"env": { "BETTERCMS_API_URL": "https://api.bettercms.ai" }
|
|
28
|
+
"args": ["-y", "@bettercms-ai/mcp"]
|
|
35
29
|
}
|
|
36
30
|
}
|
|
37
31
|
}
|
|
38
32
|
```
|
|
39
33
|
|
|
40
|
-
### Registry auth (current)
|
|
41
|
-
|
|
42
|
-
This package is published to **GitHub Packages**, which requires authentication
|
|
43
|
-
**even for public scoped packages**. For `npx @bettercms-ai/mcp` to resolve, the
|
|
44
|
-
host machine needs an `~/.npmrc` with:
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
@bettercms-ai:registry=https://npm.pkg.github.com/
|
|
48
|
-
//npm.pkg.github.com/:_authToken=<a GitHub PAT with read:packages>
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
> **TODO (future):** publish `@bettercms-ai/mcp` (and `types`/`sdk`) to the public
|
|
52
|
-
> **npmjs.org** registry so `npx @bettercms-ai/mcp` works with zero `.npmrc` setup
|
|
53
|
-
> for any host/user. Until then the GitHub Packages auth above is required.
|
|
54
|
-
|
|
55
34
|
On the first tool call the server prints an authorization prompt to **stderr**
|
|
56
35
|
(visible in your MCP client's logs):
|
|
57
36
|
|
package/dist/index.js
CHANGED
|
@@ -2801,7 +2801,7 @@ function allKeys(fields) {
|
|
|
2801
2801
|
})
|
|
2802
2802
|
]);
|
|
2803
2803
|
}
|
|
2804
|
-
var CLEARABLE = ["helpText", "showIf", "ui", "placement", "readOnly", "lockReason", "helpMore", "advanced", "recommendedMax", "openMode", "localized"];
|
|
2804
|
+
var CLEARABLE = ["helpText", "showIf", "requiredIf", "compare", "ui", "placement", "readOnly", "lockReason", "helpMore", "advanced", "recommendedMax", "openMode", "localized"];
|
|
2805
2805
|
var SETTABLE = ["label", "required", ...CLEARABLE];
|
|
2806
2806
|
function mergeConfig(current, patch) {
|
|
2807
2807
|
const out = { ...current ?? {} };
|
|
@@ -2839,7 +2839,7 @@ function planFieldEdit(model, edit, callerIfMatch) {
|
|
|
2839
2839
|
}
|
|
2840
2840
|
const changes = SETTABLE.filter((k) => edit[k] !== void 0);
|
|
2841
2841
|
if (!retype && rename === void 0 && edit.config === void 0 && changes.length === 0) {
|
|
2842
|
-
return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, readOnly, lockReason, helpMore, advanced, recommendedMax, openMode, localized, type or key." };
|
|
2842
|
+
return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, requiredIf, compare, ui, placement, readOnly, lockReason, helpMore, advanced, recommendedMax, openMode, localized, type or key." };
|
|
2843
2843
|
}
|
|
2844
2844
|
const echo = helpTextEcho(edit.helpText, { label: edit.label ?? current.label, defaultValue: current.defaultValue });
|
|
2845
2845
|
if (echo) return { ok: false, error: echo };
|
|
@@ -3009,7 +3009,7 @@ var toolMeta = (name) => ALWAYS_LOADED.has(name) ? { "anthropic/alwaysLoad": tru
|
|
|
3009
3009
|
var TOOL_LOADING_INSTRUCTION = `TOOL SCHEMAS: a client with tool search lists most BetterCMS tools by name only. Before the first call to a tool whose schema you have not been shown, load it with the client's tool search, and load every tool the task needs in ONE search (for a renaming pass: update_field, update_content_model, update_component, list_components). A call made without the schema is refused by the client before it reaches BetterCMS ("Invalid tool parameters"): that is this, never a BetterCMS error, and never a reason to change the arguments.`;
|
|
3010
3010
|
|
|
3011
3011
|
// src/tool-text.ts
|
|
3012
|
-
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; key 'a.b' adds b inside group/repeater a (after: a sibling); `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.";
|
|
3012
|
+
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; key 'a.b' adds b inside group/repeater a (after: a sibling); `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. Cross-field: `requiredIf` (showIf's shape) and `compare` [{op:gt|gte|lt|lte|eq|neq, field, message?}].";
|
|
3013
3013
|
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).";
|
|
3014
3014
|
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.";
|
|
3015
3015
|
var WINDOW_NOTE = "{from?, until?}, ISO 8601 with offset: shown only between the two instants; null clears (skill publishing).";
|
|
@@ -3135,7 +3135,7 @@ var INTEGRATION_PARAM_TEXT = {
|
|
|
3135
3135
|
var INPUT_CONFIRMED_HEADER = "x-bcms-confirmed-input";
|
|
3136
3136
|
var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant? (a Button family look), size?, arrow?, icon?, newTab?, rel?, fullWidth?} (arrow:false, icon:null off): never a text field beside a link field.";
|
|
3137
3137
|
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}.";
|
|
3138
|
-
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`.";
|
|
3138
|
+
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, requiredIf, compare, 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`.";
|
|
3139
3139
|
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.";
|
|
3140
3140
|
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.";
|
|
3141
3141
|
var SEO_KEY_TEXT = {
|
|
@@ -3401,6 +3401,16 @@ var OPEN_MODE_INVALID = "openMode_invalid: openMode must be one of 'inline', 'dr
|
|
|
3401
3401
|
var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
|
|
3402
3402
|
var fieldKey = z.string().regex(/^[a-zA-Z0-9_-]+$/, "letters, numbers, underscores, and hyphens only");
|
|
3403
3403
|
var dockIconArg = z.string().min(1).max(40).refine((v) => DOCK_ICON_SET.has(v), `not a dock icon, see ${DOCK_ICONS_URI}`);
|
|
3404
|
+
var fieldCondition = z.object({
|
|
3405
|
+
match: z.enum(["all", "any"]),
|
|
3406
|
+
rules: z.array(
|
|
3407
|
+
z.object({
|
|
3408
|
+
field: z.string().describe("the BARE KEY of another field on this same model"),
|
|
3409
|
+
op: z.enum(["eq", "neq", "in", "notIn", "gt", "lt", "isSet", "notEmpty", "empty"]),
|
|
3410
|
+
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); 'gt'/'lt' take one number as a string ("10"); required for every other op`)
|
|
3411
|
+
})
|
|
3412
|
+
)
|
|
3413
|
+
});
|
|
3404
3414
|
var uiObject = z.strictObject({
|
|
3405
3415
|
collapsed: z.boolean().optional().describe("start this panel collapsed. Use it for long optional sections."),
|
|
3406
3416
|
preview: z.strictObject({
|
|
@@ -3478,7 +3488,7 @@ var fieldShape = {
|
|
|
3478
3488
|
"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."
|
|
3479
3489
|
),
|
|
3480
3490
|
config: z.record(z.string(), z.unknown()).optional().describe(
|
|
3481
|
-
"per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, repeater {minItems, maxItems} (the list's item limits; the editor shows 'max N' and stops Add there), array {itemType: text|number|date|image|link|richtext}, date {includeTime, timezone}, modular|slot {blockSlugs: ['quote','gallery'], minItems?, maxItems?} (slot: playbook \xA75) \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}. Undocumented keys open the classic editor."
|
|
3491
|
+
"per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, repeater {minItems, maxItems} (the list's item limits; the editor shows 'max N' and stops Add there), array {itemType: text|number|date|image|link|richtext}, date {includeTime, timezone}, modular|slot {blockSlugs: ['quote','gallery'], minItems?, maxItems?} (slot: playbook \xA75) \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; richtext article blocks and references {posts?, authors?}: playbook/schema \xA75c; limits: text/longtext {minLength,maxLength,pattern,patternMessage}, richtext {minLength,maxLength}, number {validation:{min,max}}, array {minItems,maxItems}, tags {min,max}, rating {min,max,step}. Undocumented keys open the classic editor."
|
|
3482
3492
|
),
|
|
3483
3493
|
fields: z.array(z.lazy(() => fieldObject)).optional().describe(
|
|
3484
3494
|
"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."
|
|
@@ -3489,18 +3499,17 @@ var fieldShape = {
|
|
|
3489
3499
|
// below did not model them, so they were stripped after the model had gone to the trouble of
|
|
3490
3500
|
// sending them. Declaring them here is what makes the existing panel agent-writable.
|
|
3491
3501
|
helpText: z.string().max(500).optional().describe(PARAM_TEXT.helpText),
|
|
3492
|
-
showIf:
|
|
3493
|
-
match: z.enum(["all", "any"]),
|
|
3494
|
-
rules: z.array(
|
|
3495
|
-
z.object({
|
|
3496
|
-
field: z.string().describe("the BARE KEY of another field on this same model"),
|
|
3497
|
-
op: z.enum(["eq", "neq", "in", "notIn", "gt", "lt", "isSet", "notEmpty", "empty"]),
|
|
3498
|
-
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); 'gt'/'lt' take one number as a string ("10"); required for every other op`)
|
|
3499
|
-
})
|
|
3500
|
-
)
|
|
3501
|
-
}).optional().describe(
|
|
3502
|
+
showIf: fieldCondition.optional().describe(
|
|
3502
3503
|
"conditional visibility \u2014 hide this field until another field has a given value (e.g. show `external_url` only when `link_type` is 'external'). Admin-only: it changes what an author SEES, never what is stored or delivered. Rules reference fields by BARE KEY, and the key namespace is flat, so the key must exist on this model."
|
|
3503
3504
|
),
|
|
3505
|
+
requiredIf: fieldCondition.optional().describe("required only while this condition holds (showIf's shape), e.g. `address` when `delivery` eq 'ship'. Enforced on every entry write."),
|
|
3506
|
+
compare: z.array(
|
|
3507
|
+
z.object({
|
|
3508
|
+
op: z.enum(["gt", "gte", "lt", "lte", "eq", "neq"]),
|
|
3509
|
+
field: z.string().describe("the BARE KEY of the field to compare against"),
|
|
3510
|
+
message: z.string().max(200).optional().describe("the error an author reads; a default names both fields")
|
|
3511
|
+
})
|
|
3512
|
+
).max(10).optional().describe("this value against another field's, as numbers, dates or text, e.g. `end_date` [{op:'gte', field:'start_date'}]. Not judged while either is empty. Enforced on every entry write."),
|
|
3504
3513
|
ui: uiObject.optional().describe(
|
|
3505
3514
|
"AUTHORING CHROME \u2014 how this field presents in the editor; never a delivery effect. `preview` slots name a CHILD FIELD KEY and must name real children, or the write is refused with the valid keys listed. `preview`/`layout` need a nesting field, `reorderable` needs a list, and an unknown key inside `ui` is a 400, not a silent strip. No `order`: field order is the array index \u2014 use `after`. Section 14 of bettercms://playbook/schema has each key and where it is legal."
|
|
3506
3515
|
),
|
|
@@ -3661,6 +3670,8 @@ function toField(f) {
|
|
|
3661
3670
|
...f.required !== void 0 ? { required: f.required } : {},
|
|
3662
3671
|
...f.helpText !== void 0 ? { helpText: f.helpText } : {},
|
|
3663
3672
|
...f.showIf !== void 0 ? { showIf: f.showIf } : {},
|
|
3673
|
+
...f.requiredIf !== void 0 ? { requiredIf: f.requiredIf } : {},
|
|
3674
|
+
...f.compare !== void 0 ? { compare: f.compare } : {},
|
|
3664
3675
|
...f.ui !== void 0 ? { ui: f.ui } : {},
|
|
3665
3676
|
// G5: these four persist on the backend and were stripped here, like helpText once was.
|
|
3666
3677
|
...f.fieldsetId !== void 0 ? { fieldsetId: f.fieldsetId } : {},
|
|
@@ -5176,6 +5187,8 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
5176
5187
|
required: z.boolean().optional(),
|
|
5177
5188
|
config: z.record(z.string(), z.unknown()).optional().describe("per-type validation/config keys, merged into the stored config; null deletes a key"),
|
|
5178
5189
|
showIf: z.record(z.string(), z.unknown()).nullable().optional().describe("same shape as on add_field; null clears it"),
|
|
5190
|
+
requiredIf: z.record(z.string(), z.unknown()).nullable().optional().describe("same shape as on add_field; null clears it"),
|
|
5191
|
+
compare: z.array(z.record(z.string(), z.unknown())).nullable().optional().describe("same shape as on add_field; null clears it"),
|
|
5179
5192
|
ui: z.record(z.string(), z.unknown()).nullable().optional().describe("same vocabulary as on add_field; merged per key, a null key or ui:null clears"),
|
|
5180
5193
|
placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).nullable().optional(),
|
|
5181
5194
|
readOnly: z.union([z.literal(true), z.literal("developer")]).nullable().optional().describe(PARAM_TEXT.lockClears),
|
|
@@ -7080,6 +7093,49 @@ The one legitimate exception is a field that is genuinely CONTENT and happens to
|
|
|
7080
7093
|
too \u2014 a blog post's \`excerpt\`, an article's \`coverImage\`. Model those as content, name them
|
|
7081
7094
|
for what they are, and let \`generate_seo_meta\` read them.
|
|
7082
7095
|
|
|
7096
|
+
## 5c. Rich-text article blocks (V2)
|
|
7097
|
+
|
|
7098
|
+
A \`richtext\` or \`document\` value (Portable Text) holds V2's article blocks as types of its own. A
|
|
7099
|
+
field's \`config.features\` gates each one, and a new field accepts them all; a block outside its
|
|
7100
|
+
field's features is refused \`richtext.block_not_accepted:<type>\`, never stripped. Store ONLY these
|
|
7101
|
+
shapes:
|
|
7102
|
+
|
|
7103
|
+
feature stored node
|
|
7104
|
+
button a \`link\` markDef with \`button: true\`
|
|
7105
|
+
embed {_type:'embed', url, title (\u2264120), aspect: '16:9'|'4:3'|'1:1'|'9:16'}
|
|
7106
|
+
gallery {_type:'gallery', layout: 'grid'|'carousel', images: [{_key, image: {src, alt,
|
|
7107
|
+
mediaId?, crop?, focal?} | null, caption? (\u2264160)}]}, at most 24 images
|
|
7108
|
+
tableOfContents {_type:'tableOfContents', title? (\u226440), depth: '2'|'3'}
|
|
7109
|
+
footnotes INLINE {_type:'footnote', _key, note} in a body text block's children
|
|
7110
|
+
(never in a table cell or a column)
|
|
7111
|
+
columns {_type:'columns', gap: 's'|'m'|'l', columns: [{_key, content: [text blocks]}]},
|
|
7112
|
+
2 to 4, text blocks only (no block, no columns, no footnote inside)
|
|
7113
|
+
authorBio {_type:'authorBio', author?: {_ref}}
|
|
7114
|
+
relatedPosts {_type:'relatedPosts', title? (\u226440), mode: 'topic'|'newest'|'manual',
|
|
7115
|
+
count: 1-6, posts?: [{_key, _ref}] (hand-picked, at most 6, unique)}
|
|
7116
|
+
|
|
7117
|
+
\`crop\` ({top, right, bottom, left}) and \`focal\` ({x, y}) are 0\u20131 fractions of the source, the
|
|
7118
|
+
image-url shape; a \`builtin:image\`'s fields take them too. A top-level h2 or h3 may carry
|
|
7119
|
+
\`anchor\`: the id an imported page gave it.
|
|
7120
|
+
|
|
7121
|
+
**Never write what is derived.** Each is refused \`richtext.derived_field:<type>.<path>\`: embed
|
|
7122
|
+
provider, shape, src; footnote number; columns cols; tableOfContents outline; authorBio name,
|
|
7123
|
+
avatar, bio; relatedPosts posts[].title, slug, url, cover, category; and \`resolved\`, on a block or
|
|
7124
|
+
a value. Who an author is and which posts are related are resolved when the page is produced, from
|
|
7125
|
+
the field's \`config.references: { posts?, authors? }\` (content-model ids; posts absent = the
|
|
7126
|
+
entry's own collection, authors absent = no author bio). A \`topic\` block in a collection with no
|
|
7127
|
+
topic field is refused \`relatedPosts.no_topic_field\`.
|
|
7128
|
+
|
|
7129
|
+
**An X post is an \`embed\` with its url.** The server fetches its card. Never paste X's
|
|
7130
|
+
blockquote or its script: X's widget script is a site setting (\`custom_scripts.embeds.xScript\`),
|
|
7131
|
+
loaded once per page.
|
|
7132
|
+
|
|
7133
|
+
**Refused at the write, by name:** \`gallery_invalid\`, \`gallery_limit_exceeded\`, \`toc_invalid\`,
|
|
7134
|
+
\`toc_limit_exceeded\`, \`footnote_invalid\`, \`columns_invalid\`, \`columns_limit_exceeded\`,
|
|
7135
|
+
\`author_bio_invalid\`, \`related_posts_invalid\`, \`related_posts_limit_exceeded\`,
|
|
7136
|
+
\`relatedPosts.no_topic_field\`. A block still being built stores: one gallery image, no author yet,
|
|
7137
|
+
no picks yet.
|
|
7138
|
+
|
|
7083
7139
|
## 6. Blogs
|
|
7084
7140
|
|
|
7085
7141
|
author collection: name, avatar, bio
|
|
@@ -7713,7 +7769,9 @@ conversion is yours to write.
|
|
|
7713
7769
|
groups \`[{_key, label}]\`; Sanity's row of plain strings, \`rows:[{cells:["a","b"]}]\`,
|
|
7714
7770
|
is admitted too), \`bcmsCode\`, \`bcmsDivider\`, \`bcmsMention\`, \`pullQuote\`
|
|
7715
7771
|
(\`{quote, name, role?, link?}\`), \`keyTakeaways\` (\`{title?, points:[...strings]}\`,
|
|
7716
|
-
title at most 40 characters, at most 8 points),
|
|
7772
|
+
title at most 40 characters, at most 8 points), the article blocks (\xA75c: \`embed\`,
|
|
7773
|
+
\`gallery\`, \`tableOfContents\`, \`columns\`, \`authorBio\`, \`relatedPosts\`, an inline
|
|
7774
|
+
\`footnote\`), or \`bcmsBlock\` carrying a
|
|
7717
7775
|
\`schemaKey\` \u2014 and a foreign node (a Sanity image, \`{_type:"image", asset:{_ref}}\`, for one)
|
|
7718
7776
|
is not merely unrendered: it has no component here, so it
|
|
7719
7777
|
vanishes from the HTML every delivery surface reads while the stored value still looks whole.
|