@bettercms-ai/mcp 0.63.2 → 0.64.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.
Files changed (30) hide show
  1. package/README.md +1 -0
  2. package/SKILL.md +1 -1
  3. package/dist/index.js +908 -487
  4. package/dist/index.js.map +1 -1
  5. package/dist/skills/bettercms/SKILL.md +175 -0
  6. package/dist/skills/bettercms/references/astro.md +67 -0
  7. package/dist/skills/bettercms/references/bindings.md +201 -0
  8. package/dist/skills/bettercms/references/componentize.md +106 -0
  9. package/dist/skills/bettercms/references/components.md +228 -0
  10. package/dist/skills/bettercms/references/content-experiments.md +62 -0
  11. package/dist/skills/bettercms/references/content-modeling.md +185 -0
  12. package/dist/skills/bettercms/references/editor-chrome.md +88 -0
  13. package/dist/skills/bettercms/references/external-content.md +78 -0
  14. package/dist/skills/bettercms/references/finish-site.md +123 -0
  15. package/dist/skills/bettercms/references/forms-and-interactions.md +203 -0
  16. package/dist/skills/bettercms/references/frameworks.md +149 -0
  17. package/dist/skills/bettercms/references/getting-started.md +106 -0
  18. package/dist/skills/bettercms/references/localization.md +81 -0
  19. package/dist/skills/bettercms/references/media-and-brand.md +142 -0
  20. package/dist/skills/bettercms/references/next.md +93 -0
  21. package/dist/skills/bettercms/references/other-frameworks.md +66 -0
  22. package/dist/skills/bettercms/references/portable-text.md +206 -0
  23. package/dist/skills/bettercms/references/programmatic-seo.md +98 -0
  24. package/dist/skills/bettercms/references/publishing.md +146 -0
  25. package/dist/skills/bettercms/references/seo.md +156 -0
  26. package/dist/skills/bettercms/references/shopify-headless.md +52 -0
  27. package/dist/skills/bettercms/references/site-blueprints.md +250 -0
  28. package/dist/skills/bettercms/references/site-review.md +73 -0
  29. package/dist/skills/bettercms/references/write-limits.md +84 -0
  30. package/package.json +2 -2
@@ -0,0 +1,81 @@
1
+ ---
2
+ title: "Localization"
3
+ description: "Document-per-locale translations (locale, translationOf, translate_entry, delivery translations), URL-prefixed locale copies of imported builds, Layout locale variants, locale-unvarianted, hreflang, and what never to model."
4
+ ---
5
+
6
+ # Localization
7
+
8
+ **Outcome.** A multilingual site keeps every language rendering, each language's copy is
9
+ editable, and nothing invents a locale model the platform does not read.
10
+
11
+ Two things are called a "language" here, and they are separate:
12
+
13
+ - **A translation** is its own entry or page row, one per locale: `locale` is its BCP-47 tag (one
14
+ of the project's locales; null = the default language) and `translationOf` is the SOURCE row's
15
+ id. Each has its own slug, draft and publish state.
16
+ - **A locale copy of an imported build** is a URL prefix (`/fr`, `/pt-br`) proven by the page's own
17
+ `<html lang>`. It is how a converted site's languages arrive, before anyone models them.
18
+
19
+ ## 1. What exists
20
+
21
+ | Piece | How it works |
22
+ |---|---|
23
+ | The project's locales | `localeConfig.locales`, set by the owner in the dashboard (project settings). A `locale` outside it is refused with 400; an empty list means a single-language project |
24
+ | Translate an entry with AI | `translate_entry` { entryId, targetLocale, slug?, fields? } creates the translation as a DRAFT with `locale` and `translationOf` set (AI credits: 402/403 when out or not on the plan). Text and longtext are translated; rich text and documents are copied for you to translate; `meta` lists which. `409 locale_conflict`: that translation exists, so edit it instead |
25
+ | Create one by hand | `create_content_entry` or `create_page` with `locale` and `translationOf` = the source's id, never another translation's. One row per locale per source (a second is `409 locale_conflict`); `update_content_entry` / `update_page` change either, null clears |
26
+ | Find them | `list_content_entries` `where: { locale: 'fr' }`; default-language rows have a null `locale`, so they match `{ locale: { ne: 'fr' } }`, never `eq` |
27
+ | Deliver them | Every delivered entry carries `locale`, `translationOf` and `translations`: `[{ id, locale, slug }]` of its group's other PUBLISHED rows (drafts only when previewing). A delivered page carries `locale` and `translationOf` |
28
+ | Translate one string | `write_content` with `action: "translate"` and `targetLang` returns the text; apply it with `set_page_content` or `update_content_entry` |
29
+ | Locale copy of an imported page | A page whose route opens with a language prefix its own `<html lang>` confirms (`/fr` on a French page). The prefix is proven by the page's language, never by the segment's shape: `/de` is a locale only on a German page |
30
+ | Componentizing a locale copy | `get_componentize_plan` places `/fr` on its default-locale twin's components, band by band, when each pair holds the same prop types in the same order; a mismatched band stays its own section. `componentize_sections` `joinTwins` moves older copies onto their twin's components |
31
+ | Layout locale variants | A Global Layout Section can have a variant per locale (`navigation--fr`). A page renders the variant whose locale is its route's first segment, else the base |
32
+ | `locale-unvarianted` | A `get_binding_report` unmatched reason: the route resolved in a locale, the Section has no variant for it, and the base still renders there. Named, never written |
33
+
34
+ Import does not derive deeper locale routes (`/fr/about`) into foldered pages or collections: a
35
+ language prefix is a whole site, not a URL folder, so those routes keep serving from the build.
36
+ Why it matters: an edit to `/about` never reaches `/fr/about`, and nothing edits `/fr/about`
37
+ until it is a page. Say so in the coverage report.
38
+
39
+ ## 2. Never model
40
+
41
+ - **No `language`, `locale` or `lang` field** on a model, page or component, and no
42
+ `translations` reference field between entries. The language is the native `locale`, the link
43
+ is the native `translationOf`, and delivery derives `translations` from them.
44
+ - **No hreflang, alternate-URL or canonical-per-language field.** SEO is native
45
+ ([seo.md](seo.md)), and the native columns have no locale slot.
46
+ - **No per-language copy of a field** (`titleFr`, `titleEn`). It doubles every edit and leaves
47
+ the page's language implicit in a key name.
48
+
49
+ ## 3. Where hreflang comes from
50
+
51
+ The frontend writes the `<head>` links. For each entry, render `<link rel="alternate" hreflang="…">`
52
+ for itself and for every row in its delivered `translations` (the locale is the tag, the slug
53
+ builds the URL on the site's own route pattern), plus `x-default` for the default-language row.
54
+ Hosted `<head>` output emits none.
55
+
56
+ The hosted sitemap does emit them. Every listed page or entry in a translation group (the source
57
+ plus its `translationOf` rows) carries `<xhtml:link rel="alternate" hreflang>` for each listed
58
+ sibling, itself included, plus `x-default` for the source. A row with no `locale` takes the
59
+ project's `localeConfig.defaultLanguage`; with neither, it gets no link of its own. Unpublished
60
+ and noindex siblings are not listed, so they get no link either. Check the live `sitemap.xml`.
61
+
62
+ ## 4. Workflow
63
+
64
+ For a site modelled in the CMS:
65
+
66
+ 1. Confirm the owner has set the project's locales; you cannot.
67
+ 2. Per source entry, `translate_entry` into each locale (or create the row by hand), then
68
+ translate the copied rich text with `write_content` and write it with `update_content_entry`.
69
+ 3. Review each draft with the user, publish it (`publish_entries` takes many), and check the
70
+ source's delivered `translations` lists it.
71
+ 4. Emit hreflang from `translations` in the frontend (§3) and fetch each language's URL.
72
+
73
+ For a multilingual import:
74
+
75
+ 1. List the prefixes the build serves and confirm each page's `<html lang>` matches its prefix.
76
+ 2. Componentize the default language first; the plan then places each locale copy on its twin.
77
+ 3. Read `get_binding_report` on each prefixed route: `locale-unvarianted` rows are the Layout
78
+ Sections still showing the default language there. `update_layout` takes no locale, so name
79
+ those Sections to the user rather than writing translated copy onto the base.
80
+ 4. Translate with `write_content`, apply, publish, and fetch each prefixed URL.
81
+ 5. Report the routes that stay build-only (deeper locale routes) with their count.
@@ -0,0 +1,142 @@
1
+ ---
2
+ title: "Brand kit and media"
3
+ description: "Brand tokens, fonts, logo kit, image sizing and focal point, SVG and video rules, and the media host."
4
+ ---
5
+
6
+ # Brand kit, section styles and media
7
+
8
+ **Outcome.** An imported site keeps its appearance, and editors change the intended brand and
9
+ media controls through one coherent system instead of scattered CSS values.
10
+
11
+ ## Audit sources and the rendered design
12
+
13
+ Inspect CSS variables, the Tailwind or theme config, design-token files, `@font-face`, layout
14
+ components, media directories, responsive assets and computed styles on representative routes.
15
+ Compare desktop and mobile screenshots. Record where each proposed token came from and how often
16
+ it is used. One colour on one button is not a brand identity.
17
+
18
+ Build a token inventory: role, source value, proposed BetterCMS value, affected components,
19
+ confidence, approval status. Cover colours, type families, weights and scale, spacing, container
20
+ widths, radius, borders, elevation, motion and breakpoints. Group colours into semantic roles
21
+ (primary, accent, background, surface, text, muted, status colours). Keep raw source values as
22
+ evidence, never as every component's editable prop.
23
+
24
+ ## What the brand kit is, and who writes it
25
+
26
+ - `get_project` returns `brandKit`: colours (`primary`, `accent`, `background`, `surface`,
27
+ `text`, `muted`), `typography` (`headingFont`, `bodyFont`), `radius` (`sharp`/`rounded`/`pill`),
28
+ and optional `typeScale`, `spacing`, `elevation`, `motion` and gradients. A project that never
29
+ saved one still returns the default kit.
30
+ - **Colours and typography are edited in the dashboard**; no MCP tool writes them. Prepare a
31
+ precise proposal and map the source CSS to it; never say you changed them.
32
+ - **The logo mark and favicon** are written with `set_brand_assets`, by the id of a media asset
33
+ of this project (upload it first). A slot a person chose is refused unless you pass
34
+ `replace: true`; ask before passing it.
35
+ - **Gradients** are written with `set_brand_graphics`: a kind, an optional angle and 2-8 stops
36
+ that name kit colour keys, never a hex or a CSS string.
37
+ - Every brand write is a version a person can restore in the dashboard.
38
+ - The hosted renderer emits the kit as `--bcms-*` and `--brand-*` CSS custom properties, which
39
+ reach hosted pages only. A self-hosted site owns its tokens: bind its own presentation
40
+ variables to the approved kit, and declare its presentation in `bcms-presentation.json` at the
41
+ root of the deployed artifact.
42
+ - `update_project` `presentation` themes the platform's own renderer with the same closed knobs
43
+ as a release's `bcms-presentation.json`. It does not restyle a deployed build.
44
+
45
+ ## Section styles
46
+
47
+ Section style props are closed tokens, not free CSS: space is `none|xs|sm|md|lg|xl`, colour is
48
+ `none|white|black|muted|mutedBg|primary|accent`. Never put a hex value from the brand into block
49
+ props: a token follows a brand change, a copied hex does not. Style inspector properties:
50
+ `CM-27`. Expose a curated choice (for example
51
+ `Surface: Light / Dark / Brand`); never ask an editor for a hex, a class name or a font path.
52
+ Say whether a change is brand-wide or one instance, and test that a token change keeps contrast
53
+ and layout intact in every variant. Offer only controls the renderer actually applies; a missing
54
+ style binding is a code or platform task, not a dashboard promise.
55
+
56
+ ## Typography and fonts
57
+
58
+ Inventory family stacks, weights, styles, variable axes, local files, remote providers and
59
+ where each is used. Match heading and body roles to the rendered code; do not swap a display
60
+ face for the body face because it appears less often. Keep fallbacks and the loading strategy.
61
+ Self-host a font file only when its licence allows it; otherwise keep the provider reference and
62
+ list the dependency. Record licence evidence or "licence unknown".
63
+
64
+ Keep semantic HTML apart from visual size: an H2 may use a display token without becoming an
65
+ H1. Expose typography variants only where the design allows a real choice.
66
+
67
+ ## Logos, images, icons and video
68
+
69
+ - **Logo kit.** Roles: primary, reverse, dark, compact mark, wordmark, favicon, where the source
70
+ proves them; mark the rest unclassified. Keep vector originals and transparency. The navigation
71
+ logo is a stored image on the layout; replace it there ([components.md](components.md) §4),
72
+ not in each page.
73
+ - **Images.** A stored image value is an object (`url`, `altText`, asset id), never a bare
74
+ string; read it as one. Alt text, decorative state and caption belong to the usage. Keep
75
+ `srcset`/`sizes`, poster, lazy/preload intent and art direction. Decorative images render an
76
+ empty `alt`; informative ones need text that fits the context.
77
+ - **Request the size you render.** Mint resize URLs with `@bettercms-ai/image-url`
78
+ (`urlFor = imageUrlBuilder({ baseUrl: mediaUrl })`, then `urlFor(asset).size(800, 450).url()`;
79
+ also `width`, `format`, `quality`, `fit`, `dpr` 1–3) and a
80
+ `srcset` of the widths the layout actually uses. Why: the original upload is often several
81
+ thousand pixels wide, and an unresized `url` ships all of it.
82
+ - **Framing lives on the value, never in the model.** An asset carries one focal point (0–1
83
+ fractions), set with `update_media` `focalX`/`focalY` (null resets to the centre) or in the
84
+ dashboard; a URL with both dimensions and `fit` cover crops around it automatically. One
85
+ PLACEMENT can differ: the image value itself may carry `focal: { x, y }` and
86
+ `crop: { top, right, bottom, left }` (0–1, cut from each edge; the crop must leave a visible
87
+ window). The value's focal wins over the asset's; absent keys fall back to the asset. Draw a
88
+ value with `imageValueUrl(value, { w, h })` and `imageValueObjectPosition(value)` from
89
+ `@bettercms-ai/image-url`, never `.url` alone, or the crop is lost. The dashboard's asset Crop
90
+ rewrites the stored bytes for every usage. Never add focal or crop FIELDS to a model: they
91
+ belong on the image value.
92
+ - **Image block width.** An image block's `props.width` is pixels, and every lane caps it at
93
+ the column (`max-width:100%`, height auto). Leave it unset to fill the column.
94
+ - **Rich-text images.** A `builtin:image` block takes `align` (`full` | `center` | `left` |
95
+ `right`) and `size` (`small` | `medium` | `large` | `full`) in its fields. Both render on every
96
+ lane, with inline widths, so no site CSS is needed.
97
+ - **Image facts ride on the delivered value.** Delivery adds `width`, `height`, `thumbHash` (a
98
+ ThumbHash placeholder) and `focalX`/`focalY` to each image value, read at request time and
99
+ omitted while unknown (object values only: a bare URL string stays a string). Reserve the box from `width`/`height`, decode `thumbHash` for a blur-up,
100
+ and fall back to a tiny variant (`width(24)`) when it is absent.
101
+ - **Generate or edit an image.** `generate_image` { prompt, width?, height?, altText } adds an AI
102
+ image to the Media Library; `transform_image` { mediaId, prompt } saves an edited copy as a NEW
103
+ asset and keeps the original. Each costs 5 AI credits (free with the workspace's own Together
104
+ key). Always set `altText`, and use them only where the user wants generated imagery: never to
105
+ stand in for a real product, person or place.
106
+ - **SVG.** Every SVG upload is sanitised on the way in, and SVGs are served as vectors (no
107
+ raster variants). Keep safe icons as SVG and expose icon choice, size and colour token.
108
+ Functional icons need an accessible label or adjacent text. Editing SVG paths is not a CMS
109
+ feature; the text inside an SVG is not a bindable field ([bindings.md](bindings.md) §2).
110
+ - **Video.** Inventory format, host, poster, captions, autoplay/mute/loop and cost. Do not copy
111
+ every video into the media library by default; keep a streaming provider reference where the
112
+ site depends on one.
113
+
114
+ ## Media host
115
+
116
+ Use `mediaBaseUrl` from `get_project`: media is served from the CDN origin or the API origin of
117
+ the backend that owns the asset ids. `set_media_delivery` is the owner's decision, asked every
118
+ time. If a stored URL 404s, find the owning asset and report it; never re-save content to "move"
119
+ assets. How templates and resize URLs use it: [frameworks.md](frameworks.md) Media host.
120
+
121
+ ## Presentation-mode UX
122
+
123
+ Show only meaningful controls, and state the scope before a change:
124
+
125
+ ```text
126
+ Brand [sitewide, reviewed]
127
+ Logos · Colors · Typography · Spacing / Radius / Motion
128
+
129
+ Selected hero image [this instance]
130
+ Asset · Alt text / Decorative · Caption
131
+ ```
132
+
133
+ Keep provenance and licence status visible to developers, away from everyday editing.
134
+
135
+ ## Acceptance
136
+
137
+ - Every local asset resolves from the project's media store; external dependencies are listed.
138
+ - Logo roles and fonts carry evidence and licence status.
139
+ - Colour, type and spacing changes go through tokens and keep contrast and responsive layout.
140
+ - Editing a usage's alt text or caption changes the right rendered image.
141
+ - Icons work in every intended theme and state.
142
+ - Baseline and changed screenshots are compared; material differences are explained.
@@ -0,0 +1,93 @@
1
+ ---
2
+ title: "Next.js adapter"
3
+ description: "The @bettercms-ai/next client, draft and revalidation routes, rendering exports, pagination and 404 handling, and static export with the snapshot."
4
+ ---
5
+
6
+ # @bettercms-ai/next
7
+
8
+ Read this when the repo has `next.config.*`. Shared rules (hosting modes, env map, tags, value
9
+ shapes) are in [frameworks.md](frameworks.md).
10
+
11
+ ## Client
12
+
13
+ `createBetterCMS<Schema>(config)` from `@bettercms-ai/next`, typed by the file
14
+ `bettercms-codegen` writes. Config: `workspace` (slug), `apiKey` (delivery key), `revalidate`
15
+ (ISR seconds, or `false`). Keep the key server-side: `BETTERCMS_API_KEY`, never
16
+ `NEXT_PUBLIC_*`.
17
+
18
+ ## Draft preview (App Router, mounted by you)
19
+
20
+ Nothing is mounted automatically. Add the routes:
21
+
22
+ - `createDraftModeRoute({ apiUrl })` → export `GET` from the route your project's preview link
23
+ points at. It reads `?token=&redirect=`, has the backend validate the token, enables Next draft
24
+ mode, stores the token in the httpOnly `bcms-preview-token` cookie and redirects.
25
+ - `createDisableDraftRoute()` → export `GET` from an exit route.
26
+ - Read drafts with `getEntry(slug, { preview: true, previewToken })`. `preview: true` without a
27
+ token throws.
28
+
29
+ ## Revalidation route
30
+
31
+ `createRevalidateRoute({ secret })` → export `POST`. With `secret` set it verifies
32
+ `X-BetterCMS-Signature` against the raw body, then calls `revalidateTag` for each tag in the
33
+ webhook. Use the project's webhook secret from the dashboard (the package docs name the env
34
+ `BCMS_REVALIDATE_SECRET`; any server env works). Tag your reads with the backend's tag strings
35
+ ([frameworks.md](frameworks.md) Revalidation).
36
+
37
+ ## Rendering
38
+
39
+ | Export | Use |
40
+ |---|---|
41
+ | `BcmsBlocks` | Render a block tree; pass `components` from `readComponents()` |
42
+ | `readComponents()` | Component definitions from `bcms-content.json` |
43
+ | `BcmsField` | `path`, `kind` (`richtext`/`image`), `as`; server-safe |
44
+ | `bind`, `bindProps` | Binding attributes; instance-aware inside components |
45
+ | `BcmsForm` + `getForm(idOrName)` | Forms from the snapshot ([forms-and-interactions.md](forms-and-interactions.md)) |
46
+ | `buildMetadata(page, siteDefaults, location?)` | Next `Metadata` from native SEO fields |
47
+ | `llmsTxtRoute(config)` | An `llms.txt` route handler |
48
+ | `imageUrlBuilder({ baseUrl })` | Resize URLs. No `BcmsImage` in Next; same media-host rules as Astro |
49
+
50
+ Rules:
51
+
52
+ - Binding attributes are always rendered; they are inert for visitors. Do not gate them behind
53
+ draft mode.
54
+ - Pass `location` `{ siteUrl, path }` to `buildMetadata`. Without it the site URL comes from
55
+ `BETTERCMS_SITE_URL` and the path from the page slug; an app that serves pages under a prefix
56
+ (`/docs/[slug]`) must pass `location.path` or its canonical is wrong.
57
+ - The package ships no sitemap or robots helper. Write `app/sitemap.ts` and `app/robots.ts`
58
+ yourself from published content, and leave out pages marked noindex.
59
+
60
+ ## Lists, pagination and missing content
61
+
62
+ - `listEntries(model, { page, perPage })`: `page` is 1-based; `perPage` defaults to 20 and the
63
+ API caps it at 100. The result carries `items`, `totalItems`, `totalPages`, `hasNextPage` and
64
+ `hasPreviousPage`: drive the pager from those, never from `items.length`. Pass `select` on list
65
+ pages so each card fetches only the keys it renders.
66
+ - A page route (`/blog/page/[n]`) answers `notFound()` when `n` is past `totalPages`; page 1 of an
67
+ empty collection is a 200 with an empty state, not a 404. Prerender pages 1…`totalPages` from
68
+ `generateStaticParams`.
69
+ - `getEntry(slug)` returns `null` on a 404: call `notFound()` on `null`, never render an empty
70
+ shell with a 200. Why: an empty 200 is indexed as a thin page, and a typo'd slug never reads as
71
+ missing.
72
+ - `listEntries` THROWS `BetterCMSError` on a 404 (an unknown workspace or model), on purpose: an
73
+ empty list there would hide a wrong model slug. Let it fail the build; do not catch it into `[]`.
74
+
75
+ ## Static export (`output: "export"`)
76
+
77
+ - Run `bettercms-snapshot` before `next build`, every build (make it the prebuild step). It writes
78
+ `bcms-content.json` from `BETTERCMS_API_URL`, `BETTERCMS_API_KEY`, `BETTERCMS_WORKSPACE`.
79
+ - Without the snapshot the reads throw `SNAPSHOT_MISSING`. Why it throws instead of fetching:
80
+ a live fetch would turn pages into dynamic routes, and a static export silently leaves them out
81
+ of `out/`.
82
+ - A preview build that must read live sets `BETTERCMS_PREVIEW=1`.
83
+ - Draft mode and the revalidation route need a server; a static export has neither. Content
84
+ changes reach a static site only by rebuilding it.
85
+
86
+ ## Traps
87
+
88
+ | Symptom | Cause | Rule |
89
+ |---|---|---|
90
+ | Empty `out/` after `next build` | No snapshot | Run `bettercms-snapshot` first |
91
+ | Page never updates after publish | Read tags do not match backend tags | Use `bcms:{workspace}:…` tags |
92
+ | Component instance renders nothing | `components` not passed | `readComponents()` into `BcmsBlocks` |
93
+ | Canonical missing or wrong only on the self-hosted site | `buildMetadata` without `location`, and no `BETTERCMS_SITE_URL` or a prefixed route | Pass `{ siteUrl, path }` |
@@ -0,0 +1,66 @@
1
+ ---
2
+ title: "Other frameworks"
3
+ description: "Reading BetterCMS content from Nuxt, SvelteKit, React Router or Vite through the SDK createClient read core: options, 404 behaviour, env names, and what only the Astro and Next adapters provide."
4
+ ---
5
+
6
+ # Other frameworks
7
+
8
+ Read this when the repo has neither `astro.config.*` nor `next.config.*`. Only Astro and Next have
9
+ adapters; everything else reads through the framework-agnostic core in `@bettercms-ai/sdk`, the
10
+ same one both adapters wrap. Shared rules (hosting modes, value shapes, media host, revalidation
11
+ tags) are in [frameworks.md](frameworks.md).
12
+
13
+ ## 1. The client
14
+
15
+ ```ts
16
+ import { createClient } from "@bettercms-ai/sdk";
17
+
18
+ export const bcms = createClient({
19
+ apiUrl: process.env.BCMS_API_URL!, // the backend origin; the SDK adds /api/v1/delivery/<workspace>
20
+ workspace: process.env.BCMS_WORKSPACE!,
21
+ projectId: process.env.BCMS_PROJECT_ID, // scopes listForms
22
+ });
23
+ ```
24
+
25
+ Reading published content needs no key. The platform sets `BCMS_API_URL`, `BCMS_WORKSPACE` and
26
+ `BCMS_PROJECT_ID` on every build and running server app, and again under each framework's public
27
+ prefix (`NUXT_PUBLIC_`, `PUBLIC_`, `VITE_`…); the table is in
28
+ [external-content.md](external-content.md). Never add them to `.env`.
29
+
30
+ | Option | Use |
31
+ |---|---|
32
+ | `perspective: "drafts"` | Draft reads; needs a `content:read:draft` key or a `previewToken`. Stega is on by default there ([bindings.md](bindings.md) §5) |
33
+ | `cache: true` | Collapse identical GETs for the client's life. Only for a static build (one point-in-time dataset); never in dev, SSR or drafts |
34
+ | `timeout` | Per-attempt ms, default 10000 |
35
+
36
+ ## 2. Methods and their 404s
37
+
38
+ | Method | Returns | On a missing slug |
39
+ |---|---|---|
40
+ | `getPage(slug)` | the page (`blocks`, meta columns) | `null`, after scanning the page list |
41
+ | `getEntry(slug, { depth, select })` | one entry | throws `BetterCMSError` with `status` 404 |
42
+ | `listEntries({ model, page, perPage, depth, select })` | `{ items, page, perPage, totalItems, totalPages, hasNextPage, hasPreviousPage }` | a list 404 (unknown model or workspace) throws |
43
+ | `listPages`, `listComponents`, `getLayout`, `listForms` | pages, component definitions, the Global Layout (`null` if none), forms (render only `status: "published"`) | |
44
+
45
+ Catch the 404 on `getEntry` and answer the framework's not-found (a 404 status, never an empty
46
+ 200). Let a list 404 fail the build. Drive pagers from `totalPages`/`hasNextPage`.
47
+
48
+ ## 3. Where to call it
49
+
50
+ Call the client only in code that runs on the server or at build time, so a key (drafts) never
51
+ reaches the bundle: a Nuxt server route or `useAsyncData` on the server, a SvelteKit
52
+ `+page.server.ts` `load`, a React Router `loader`. A browser-only Vite SPA reads published content
53
+ with the public-prefixed variables and no key.
54
+
55
+ ## 4. What only the adapters give you
56
+
57
+ No `BcmsBlocks`/`BcmsField` components, no `bind`/`bindProps` helpers, no draft routes and no
58
+ snapshot bin exist for these frameworks. So:
59
+
60
+ - Render component blocks yourself from `listComponents()` definitions, and write binding
61
+ attributes by hand per [bindings.md](bindings.md) (`data-bcms-field`, `data-bcms-block`).
62
+ - Rich text: read it through the SDK's `plain`, `rich` or `portableText` helpers, which accept a
63
+ bare string or the `{ format, value, html }` envelope.
64
+ - Images: `imageUrlBuilder` is re-exported from the SDK ([media-and-brand.md](media-and-brand.md)).
65
+ - Meta: `resolveSeo` merges page over site defaults; write the result into `<head>` yourself.
66
+ - Verify with the two-path smoke test in [frameworks.md](frameworks.md) Verify.
@@ -0,0 +1,206 @@
1
+ ---
2
+ title: "Field types and Portable Text"
3
+ description: "Choosing field types, the Portable Text write contract and grammar, converting HTML or Markdown structurally, and serializing rich text."
4
+ ---
5
+
6
+ # Field types and Portable Text
7
+
8
+ **Outcome.** The editor shows the right control for each visible value, the page keeps its
9
+ semantic HTML, a button or card title cannot turn into the wrong heading level, and every rich
10
+ text value you write is stored and rendered as written. Binding grammar:
11
+ [bindings.md](bindings.md); field and schema doctrine: `bettercms://playbook/schema`.
12
+
13
+ ## 1. Decide what the user is editing
14
+
15
+ Inspect the rendered element, its source component, role, ancestors, repeated instances and
16
+ responsive variants, and the existing schema. Keep four concerns apart; they must not collapse
17
+ into one rich-text field:
18
+
19
+ - **content** (the words),
20
+ - **semantic tag** (H1, H2, p, button),
21
+ - **presentation** (font, size, colour),
22
+ - **behaviour** (href, modal trigger).
23
+
24
+ Choose a field's type, label, validation and allowed rich-text features from its context on the
25
+ page, not from a rule that every string is rich text. When one source value renders in two
26
+ semantic contexts, split the display role from the content, or make the level a controlled
27
+ component prop, never an open toolbar choice.
28
+
29
+ ## 2. Semantic field matrix
30
+
31
+ The model's field types are `text`, `richtext`, `document` (the article body, at most one per
32
+ collection), `image`, `boolean`, `number`, `select` and references, among others. The matrix is
33
+ intended UX; confirm each control exists in the connected editor before promising it.
34
+
35
+ | Rendered role | Field | Editor options | Render rule |
36
+ |---|---|---|---|
37
+ | Page or hero title | text, or one-block richtext | title role only; inline emphasis if the design needs it | one H1 per page; a title shown twice is not two H1s |
38
+ | Section heading | text | no heading picker; level comes from the component | usually H2, H3 when nested |
39
+ | Card or item title | text | no toolbar | H3/H4 by nesting; a reused card never adds an H1 |
40
+ | Eyebrow, badge, button label, nav item, form label | text | plain | keep the real element (`span`, `a`, `button`, `label`) |
41
+ | Summary | text; richtext only when inline links are needed | normal paragraphs, few marks | `p`; no heading or list controls |
42
+ | Article body | `document` | the schema's allowed blocks | keep the whole outline and every supported node |
43
+ | FAQ question | text | plain | inside an accessible trigger; the component owns the level |
44
+ | FAQ answer, modal body | narrowed richtext | paragraphs, lists, links, emphasis | focus and ARIA live outside the value |
45
+ | Image | image | asset, alt text, caption | bind `src` and `alt` separately |
46
+ | Link or button | grouped fields | label, link, style variant | no raw HTML, no `javascript:` URL |
47
+
48
+ "Disabled" controls mean the field's accept list removes irrelevant formatting; the field stays
49
+ editable. Keep a source's unusual but valid hierarchy when changing it would alter meaning, and
50
+ flag it for review. The page title is the page's `h1`; body rich text starts at `h2`. Group the
51
+ side panel by what the editor sees (`Button → Label, Link, Style`;
52
+ `Image → Asset, Alt text, Caption`) and show the instance scope.
53
+
54
+ ## 3. The write contract
55
+
56
+ - Write a richtext value as a Portable Text array. An array passes straight through to storage.
57
+ - Never write a bare string to a richtext field. On write a bare string is parsed as HTML or
58
+ Markdown (a conversion you did not review), while the reader treats a bare string as literal
59
+ text, so the same string means two different things depending on the path.
60
+ - The stored value is an envelope `{ format: "portable-text-1", value, html }`. `value` is the
61
+ truth; `html` is a derived cache. Never write or patch `html`.
62
+ - A per-paragraph address (`base#<_key>`) is an edit target, not a write path: write the whole
63
+ field ([bindings.md](bindings.md) §3).
64
+ - The `/presence/pt-patch` endpoint relays live edits between people in the editor. It is not a
65
+ save. Persist through the content write tools.
66
+
67
+ ## 4. Portable Text rules
68
+
69
+ Names come from `packages/richtext/src/schema.ts`. A field's `accept` list narrows them; use only
70
+ names the field accepts.
71
+
72
+ | Part | Allowed names | Granted by accept element |
73
+ |---|---|---|
74
+ | block `style` | `normal` always; `h1`…`h6`; `blockquote` | heading element; `p` |
75
+ | decorators (span `marks`) | `strong`, `em`, `underline`, `strike-through`, `code`, `highlight` | `b`, `i`, `u`, `s`, `code`, `mark` |
76
+ | annotations (`markDefs`) | `link` {href, target, className}; `styled` {tag, className, attrs} | `link`/`internalLink`; always |
77
+ | `listItem` | `bullet`, `number`, `check` | `ul`, `ol`, `checklist` |
78
+ | block objects | `bcmsDivider`; `bcmsCode` {code, language}; `bcmsBlock` {schemaKey, schemaLabel, fields}; `table` {rows} | `p`; `pre`; `image`/`embed`/`columns`; `table` |
79
+ | inline objects | `bcmsMention` {label} | always |
80
+
81
+ - **Keys**: every block, span, markDef, row and cell has `_key` matching `^[A-Za-z0-9-]{1,32}$`,
82
+ unique in the document. Keys are editor addresses, so keep existing ones.
83
+ - **Marks**: a span's `marks` entry is a decorator name or the `_key` of a markDef in the same
84
+ block. Every markDef is referenced by at least one span.
85
+ - **Lists are flat**: a list item is a normal block with `listItem` and `level` (1 = top). Never
86
+ nest blocks inside a list container. A `check` item carries `checked: true|false`.
87
+ - **Tables**: a cell holds blocks in `content`, not a string. `header: true` marks a `<th>` cell;
88
+ `head: true` puts a row in `<thead>`.
89
+ - **`styled`** carries a site's own inline markup; `tag` is one of `span`, `small`, `sup`, `sub`,
90
+ `kbd`, `abbr`, `time`, or a decorator tag (`strong`, `em`, `b`, `i`, `u`, `s`, `mark`, `code`, …)
91
+ that carries a class. A `link` keeps its class as `className`.
92
+ - **Top-level `_type`** is one of `block`, `bcmsBlock`, `table`, `bcmsCode`, `bcmsDivider`,
93
+ `bcmsMention`. A name outside that list is refused on write, by name and `_key`, because the
94
+ block would disappear from the live site. The refusal is **top level only**: a foreign mark
95
+ passes and its text renders unmarked; a foreign inline child passes and renders nothing. Check
96
+ those yourself.
97
+
98
+ A paragraph with a bold word and a link:
99
+
100
+ <!-- pt-example -->
101
+ ```json
102
+ [
103
+ {
104
+ "_type": "block",
105
+ "_key": "intro",
106
+ "style": "normal",
107
+ "markDefs": [{ "_type": "link", "_key": "lnk1", "href": "/pricing" }],
108
+ "children": [
109
+ { "_type": "span", "_key": "s1", "text": "See ", "marks": [] },
110
+ { "_type": "span", "_key": "s2", "text": "pricing", "marks": ["strong", "lnk1"] }
111
+ ]
112
+ }
113
+ ]
114
+ ```
115
+
116
+ A flat two-level bullet list:
117
+
118
+ <!-- pt-example -->
119
+ ```json
120
+ [
121
+ { "_type": "block", "_key": "li1", "style": "normal", "listItem": "bullet", "level": 1, "markDefs": [],
122
+ "children": [{ "_type": "span", "_key": "li1s", "text": "Plans", "marks": [] }] },
123
+ { "_type": "block", "_key": "li2", "style": "normal", "listItem": "bullet", "level": 2, "markDefs": [],
124
+ "children": [{ "_type": "span", "_key": "li2s", "text": "Monthly", "marks": [] }] }
125
+ ]
126
+ ```
127
+
128
+ A table with a header row:
129
+
130
+ <!-- pt-example -->
131
+ ```json
132
+ [
133
+ {
134
+ "_type": "table",
135
+ "_key": "tbl",
136
+ "rows": [
137
+ { "_key": "r1", "head": true, "cells": [
138
+ { "_key": "c1", "header": true, "content": [
139
+ { "_type": "block", "_key": "c1b", "style": "normal", "markDefs": [],
140
+ "children": [{ "_type": "span", "_key": "c1s", "text": "Plan", "marks": [] }] }
141
+ ] }
142
+ ] },
143
+ { "_key": "r2", "cells": [
144
+ { "_key": "c2", "content": [
145
+ { "_type": "block", "_key": "c2b", "style": "normal", "markDefs": [],
146
+ "children": [{ "_type": "span", "_key": "c2s", "text": "Pro", "marks": [] }] }
147
+ ] }
148
+ ] }
149
+ ]
150
+ }
151
+ ]
152
+ ```
153
+
154
+ Site markup kept through `styled`:
155
+
156
+ <!-- pt-example -->
157
+ ```json
158
+ [
159
+ {
160
+ "_type": "block",
161
+ "_key": "price",
162
+ "style": "h2",
163
+ "markDefs": [{ "_type": "styled", "_key": "sm1", "tag": "small", "className": "muted" }],
164
+ "children": [
165
+ { "_type": "span", "_key": "p1", "text": "$29 ", "marks": [] },
166
+ { "_type": "span", "_key": "p2", "text": "per month", "marks": ["sm1"] }
167
+ ]
168
+ }
169
+ ]
170
+ ```
171
+
172
+ ## 5. Converting into Portable Text
173
+
174
+ 1. Parse HTML, Markdown, MDX or source-CMS blocks structurally; never flatten markup with regex.
175
+ 2. Map headings, paragraphs, lists, links, marks, tables, images and embedded components to
176
+ supported nodes. Resolve internal links to page or entry references where possible.
177
+ 3. Sanitise HTML and URLs. Keep the source path and write a loss report for every unsupported
178
+ node, embed or script. A foreign custom block needs a renderer and a mapping, or a visible
179
+ conversion warning.
180
+ 4. Generate deterministic keys on the first import; preserve them on later edits and re-imports.
181
+ 5. Compare text, link count and targets, heading outline, list levels and media references
182
+ between source and result.
183
+
184
+ ## 6. Serializing out of Portable Text
185
+
186
+ 1. Render every allowed style, list type, decorator, annotation, inline object and custom block
187
+ explicitly. Report unknown nodes; never drop them silently.
188
+ 2. Use a context-aware renderer for internal links, images and embeds, and keep their
189
+ accessibility labels.
190
+ 3. Build plain-text and SEO excerpts from the semantic text, with preview markers stripped
191
+ (`stripStega`) and no HTML or hidden JSON.
192
+ 4. Markdown export loses interactive custom blocks; emit typed references or a loss report, and
193
+ never claim a lossless round trip.
194
+
195
+ The public Portable Text specification describes the general block model. BetterCMS's own schema
196
+ and accept list decide the subset; do not paste Sanity-only types or serializers.
197
+
198
+ ## Acceptance
199
+
200
+ - Page title, section title, card title, button label, summary and article body each open the
201
+ expected controls; a reused card on a second page adds no H1.
202
+ - Rich text keeps links, nested marks, lists, tables, custom blocks and stable keys; unknown
203
+ nodes are logged, and nothing disappears silently.
204
+ - Changing a label, a URL or a style changes only that property on that instance.
205
+ - Long, empty and translated text render acceptably.
206
+ - After writing rich text, `get_binding_report` for the route is clean.