@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.
- package/README.md +1 -0
- package/SKILL.md +1 -1
- package/dist/index.js +908 -487
- package/dist/index.js.map +1 -1
- package/dist/skills/bettercms/SKILL.md +175 -0
- package/dist/skills/bettercms/references/astro.md +67 -0
- package/dist/skills/bettercms/references/bindings.md +201 -0
- package/dist/skills/bettercms/references/componentize.md +106 -0
- package/dist/skills/bettercms/references/components.md +228 -0
- package/dist/skills/bettercms/references/content-experiments.md +62 -0
- package/dist/skills/bettercms/references/content-modeling.md +185 -0
- package/dist/skills/bettercms/references/editor-chrome.md +88 -0
- package/dist/skills/bettercms/references/external-content.md +78 -0
- package/dist/skills/bettercms/references/finish-site.md +123 -0
- package/dist/skills/bettercms/references/forms-and-interactions.md +203 -0
- package/dist/skills/bettercms/references/frameworks.md +149 -0
- package/dist/skills/bettercms/references/getting-started.md +106 -0
- package/dist/skills/bettercms/references/localization.md +81 -0
- package/dist/skills/bettercms/references/media-and-brand.md +142 -0
- package/dist/skills/bettercms/references/next.md +93 -0
- package/dist/skills/bettercms/references/other-frameworks.md +66 -0
- package/dist/skills/bettercms/references/portable-text.md +206 -0
- package/dist/skills/bettercms/references/programmatic-seo.md +98 -0
- package/dist/skills/bettercms/references/publishing.md +146 -0
- package/dist/skills/bettercms/references/seo.md +156 -0
- package/dist/skills/bettercms/references/shopify-headless.md +52 -0
- package/dist/skills/bettercms/references/site-blueprints.md +250 -0
- package/dist/skills/bettercms/references/site-review.md +73 -0
- package/dist/skills/bettercms/references/write-limits.md +84 -0
- 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.
|