@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. Lets an
4
- AI assistant author your content schema and entries through the BetterCMS Management
5
- API, authenticated with a short-lived, per-project `content:manage` key minted via
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
- ## Tools
9
-
10
- | Tool | Description |
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
- See [SKILL.md](./SKILL.md) for how an AI agent should author pages, forms, and components.
12
+ ## Tools
20
13
 
21
- All operations are **additive** — there are no destructive tools. Deleting models,
22
- fields, or entries is dashboard-only by design.
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: z.object({
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), or \`bcmsBlock\` carrying a
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.