@bettercms-ai/mcp 0.83.0 → 0.83.2
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 +86 -86
- package/SKILL.md +160 -160
- package/dist/index.js +38 -9
- package/dist/index.js.map +1 -1
- package/dist/kit-catalog.json +1509 -1509
- package/dist/skills/bettercms/SKILL.md +206 -206
- package/dist/skills/bettercms/references/accessibility.md +171 -171
- package/dist/skills/bettercms/references/animation.md +296 -296
- package/dist/skills/bettercms/references/astro.md +148 -148
- package/dist/skills/bettercms/references/bindings.md +366 -366
- package/dist/skills/bettercms/references/componentize.md +185 -185
- package/dist/skills/bettercms/references/components.md +415 -415
- package/dist/skills/bettercms/references/content-experiments.md +62 -62
- package/dist/skills/bettercms/references/content-modeling.md +382 -382
- package/dist/skills/bettercms/references/detection.md +465 -465
- package/dist/skills/bettercms/references/dock-input.md +121 -121
- package/dist/skills/bettercms/references/editor-chrome.md +92 -92
- package/dist/skills/bettercms/references/external-content.md +78 -78
- package/dist/skills/bettercms/references/finish-site.md +377 -377
- package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
- package/dist/skills/bettercms/references/frameworks.md +159 -159
- package/dist/skills/bettercms/references/getting-started.md +156 -156
- package/dist/skills/bettercms/references/integrations.md +364 -364
- package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
- package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
- package/dist/skills/bettercms/references/localization.md +85 -85
- package/dist/skills/bettercms/references/media-and-brand.md +181 -181
- package/dist/skills/bettercms/references/next.md +189 -189
- package/dist/skills/bettercms/references/other-frameworks.md +85 -85
- package/dist/skills/bettercms/references/portable-text.md +213 -213
- package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
- package/dist/skills/bettercms/references/publishing.md +289 -289
- package/dist/skills/bettercms/references/seo.md +357 -357
- package/dist/skills/bettercms/references/shopify-headless.md +52 -52
- package/dist/skills/bettercms/references/site-blueprints.md +895 -895
- package/dist/skills/bettercms/references/site-review.md +74 -74
- package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
- package/dist/skills/bettercms/references/worked-examples.md +885 -885
- package/dist/skills/bettercms/references/write-limits.md +217 -217
- package/package.json +1 -1
|
@@ -1,296 +1,296 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Animated sites"
|
|
3
|
-
description: "Keeping GSAP, ScrollTrigger, SplitText, Framer Motion, Lenis, marquees, carousels, Lottie and hydrated islands working while their copy stays editable and publishable."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Animated sites: editable copy that keeps moving
|
|
7
|
-
|
|
8
|
-
Read this when `package.json` lists `gsap`, `lenis`, `locomotive-scroll`, `framer-motion` or
|
|
9
|
-
`motion`, `swiper`, `split-type`, `splitting`, `lottie-web`, `three` or `@react-three/fiber`, or
|
|
10
|
-
when the built HTML carries
|
|
11
|
-
`astro-island`, split-text spans or a duplicated marquee track. Attribute grammar:
|
|
12
|
-
[bindings.md](bindings.md). Adapter wiring: [frameworks.md](frameworks.md).
|
|
13
|
-
|
|
14
|
-
## 1. What a publish does to animated markup
|
|
15
|
-
|
|
16
|
-
The bound element picks the writer. One that holds text and nothing else is replaced whole, as
|
|
17
|
-
it always was. One that holds markup (inline elements, split spans, a `<br>`, or
|
|
18
|
-
`data-bcms-split`) is written into its text nodes, and every element inside it is kept. That is
|
|
19
|
-
what the recipes below rely on:
|
|
20
|
-
|
|
21
|
-
| The page has | A publish does |
|
|
22
|
-
|---|---|
|
|
23
|
-
| Inline markup inside the bound element (`<em>`, a highlight `<span>`, `<br>`) | changes the words, keeps the elements and their attributes; a bare `<em>` or `<span>` the new words leave empty is removed |
|
|
24
|
-
| Text cut into `word` / `char` / `line` spans, same word count | writes each span in place |
|
|
25
|
-
| The same, word count changed, spans made by a library at runtime | puts the plain text back; the library splits it on its next run |
|
|
26
|
-
| The same, spans written by your own template | rebuilds them from their own pattern, a new one copied from the last |
|
|
27
|
-
| A split headline under an `aria-label` that says the same words (SplitText's own output) | one field on the headline: writes the label and the words |
|
|
28
|
-
| A counter with its number in `data-target`, `data-count` or a like attribute | writes the attribute; writes the number shown only where it showed the old target, never a script's starting `0` |
|
|
29
|
-
| A copy of the value under `aria-hidden="true"` or `data-bcms-clone` | writes the copy too (coverage kind `duplicate-track`) |
|
|
30
|
-
| The value inside `astro-island` props, `__NEXT_DATA__` or the app router's flight scripts | rewrites the serialized string (coverage kind `island-props`) |
|
|
31
|
-
|
|
32
|
-
It refuses, and says so on the publish, when the change would have to remove something:
|
|
33
|
-
`TEXT_IN_CHILD_FIELD` (the words belong to a field declared inside this one),
|
|
34
|
-
`TEXT_EMPTIES_ELEMENT` (a link, a scripted element or a wrapper with attributes would be left empty),
|
|
35
|
-
`SPLIT_PATTERN_UNKNOWN` (split markup that is not one row of like tokens) and
|
|
36
|
-
`ISLAND_PROPS_UNMATCHED` (a hydrated island draws the text from props that do not hold it).
|
|
37
|
-
Each is fixed in the template with the recipe for its library.
|
|
38
|
-
|
|
39
|
-
The last two rows follow a value only where the element was written into its text nodes. An
|
|
40
|
-
operator can switch a project, or the fleet, to write every text value that way, so a plain-text
|
|
41
|
-
value's copies and props follow it too; and can switch the text-node writer off, after which a
|
|
42
|
-
publish replaces an element's content with plain text and refuses what that would break.
|
|
43
|
-
|
|
44
|
-
## 2. Two rules for every library
|
|
45
|
-
|
|
46
|
-
1. **Bind the element that holds the words, never the element the animation owns.** A pinned
|
|
47
|
-
section, a scroll wrapper, a slider track and a motion container are layout. Their text
|
|
48
|
-
leaves are the fields.
|
|
49
|
-
2. **Refresh measurements after an edit.** The editor posts `{ type: "bcms:content-applied" }`
|
|
50
|
-
to the page's window after it applies an edit. Longer copy changes heights, so anything that
|
|
51
|
-
measured the page must measure again:
|
|
52
|
-
|
|
53
|
-
```js
|
|
54
|
-
window.addEventListener("message", (event) => {
|
|
55
|
-
if (event.data?.type !== "bcms:content-applied") return;
|
|
56
|
-
ScrollTrigger.refresh(); // or lenis.resize(), swiper.update(), split.revert() then split again
|
|
57
|
-
});
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
`onContentApplied(handler)` from `@bettercms-ai/sdk` is the same listener with the check
|
|
61
|
-
written for you. On a published page nothing posts the message and the listener never runs.
|
|
62
|
-
|
|
63
|
-
## 3. GSAP and ScrollTrigger
|
|
64
|
-
|
|
65
|
-
```html
|
|
66
|
-
<section class="pin-wrap"> <!-- trigger and pin: no field -->
|
|
67
|
-
<div class="pin">
|
|
68
|
-
<h2 data-bcms-field="story.title">Built to last</h2>
|
|
69
|
-
<p data-bcms-field="story.lead">Forged, not cast.</p>
|
|
70
|
-
</div>
|
|
71
|
-
</section>
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
```js
|
|
75
|
-
gsap.to(".pin h2", { yPercent: -20, scrollTrigger: { trigger: ".pin-wrap", pin: ".pin", scrub: 1 } });
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Never put `data-bcms-field` on `.pin-wrap` or `.pin`: ScrollTrigger wraps a pinned element in
|
|
79
|
-
a spacer and writes inline styles on it every frame. Use `<BcmsField>` only around a single
|
|
80
|
-
element, where it adds no wrapper (section 10); a wrapper span between `.pin` and its `h2`
|
|
81
|
-
would break `.pin > h2`.
|
|
82
|
-
|
|
83
|
-
Check: with the page open, `ScrollTrigger.getAll().length` is the same before and after a
|
|
84
|
-
publish of every field on the route.
|
|
85
|
-
|
|
86
|
-
## 4. SplitText, SplitType, Splitting.js
|
|
87
|
-
|
|
88
|
-
Bind the parent and say that it is split. Never bind a word or a character.
|
|
89
|
-
|
|
90
|
-
```html
|
|
91
|
-
<h1 class="js-split" data-bcms-field="hero.title" data-bcms-split="words">Fast screws, shipped</h1>
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
```js
|
|
95
|
-
let split = new SplitText(".js-split", { type: "words" });
|
|
96
|
-
window.addEventListener("message", (event) => {
|
|
97
|
-
if (event.data?.type !== "bcms:content-applied") return;
|
|
98
|
-
split.revert();
|
|
99
|
-
split = new SplitText(".js-split", { type: "words" });
|
|
100
|
-
});
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
`data-bcms-split` takes `chars`, `words` or `lines`. It tells the editor that the spans under
|
|
104
|
-
this element are one value, and it tells a publish that the children are tokens even when their
|
|
105
|
-
class is not `word`, `char` or `line`.
|
|
106
|
-
|
|
107
|
-
When your own template writes the spans (`title.split(" ").map(...)`), keep them one row of
|
|
108
|
-
the same tag, with whitespace or nothing between them, and no other element in the row:
|
|
109
|
-
|
|
110
|
-
```astro
|
|
111
|
-
<h1 data-bcms-field="hero.title" data-bcms-split="words">
|
|
112
|
-
{title.split(" ").map((word) => <span class="word">{word}</span>)}
|
|
113
|
-
</h1>
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
A `<br>` between two word spans makes the row unreadable once the word count changes
|
|
117
|
-
(`SPLIT_PATTERN_UNKNOWN`): put the break in CSS, or split by lines.
|
|
118
|
-
|
|
119
|
-
Check: publish a title with one more word, reload, and count `.word`: it equals the new word
|
|
120
|
-
count and no word sits outside a span.
|
|
121
|
-
|
|
122
|
-
## 5. Framer Motion
|
|
123
|
-
|
|
124
|
-
```tsx
|
|
125
|
-
<motion.section initial={{ opacity: 0 }} whileInView={{ opacity: 1 }}> {/* no field */}
|
|
126
|
-
<motion.h2 data-bcms-field="story.title" variants={rise}>{story.title}</motion.h2>
|
|
127
|
-
<p data-bcms-field="story.lead">{story.lead}</p>
|
|
128
|
-
</motion.section>
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
Put `data-bcms-field` straight on the motion element that renders the text: it passes
|
|
132
|
-
`data-*` through to the DOM. Do not wrap a motion component in `<BcmsField>`; a component child
|
|
133
|
-
keeps the wrapper span, and `layout` animations measure the wrong box. For words animated one
|
|
134
|
-
by one, map them to `motion.span` under a parent carrying `data-bcms-split="words"`, as in
|
|
135
|
-
section 4. The component hydrates, so section 9 applies to its props.
|
|
136
|
-
|
|
137
|
-
Check: the built HTML for the route shows `data-bcms-field` on the `h2` itself, with no
|
|
138
|
-
`display:contents` span around it.
|
|
139
|
-
|
|
140
|
-
## 6. Lenis and other smooth scroll
|
|
141
|
-
|
|
142
|
-
Lenis moves the page, not the content, so no binding changes. Two things do:
|
|
143
|
-
|
|
144
|
-
```js
|
|
145
|
-
const lenis = new Lenis();
|
|
146
|
-
window.addEventListener("message", (event) => {
|
|
147
|
-
if (event.data?.type === "bcms:content-applied") lenis.resize();
|
|
148
|
-
});
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
and any panel of yours that must scroll by itself inside the page takes `data-lenis-prevent`.
|
|
152
|
-
Never bind the element Lenis is given as `wrapper` or `content`.
|
|
153
|
-
|
|
154
|
-
In the Visual Editor the canvas owns the scroll: it marks the page's `<body>` with
|
|
155
|
-
`data-lenis-prevent`, so the wheel there is the browser's own and has no easing. That is the
|
|
156
|
-
editor, not a fault in the site: Lenis still reports every scroll (ScrollTrigger and anything
|
|
157
|
-
else hung on `lenis.on("scroll")` runs), its own `scrollTo` still eases, and the published page
|
|
158
|
-
is untouched. Do not remove the attribute or "repair" the easing for the canvas. What other
|
|
159
|
-
scroll libraries need there is in section 12.
|
|
160
|
-
|
|
161
|
-
Check: after an edit that adds two lines to a paragraph, the page still scrolls to the footer.
|
|
162
|
-
|
|
163
|
-
## 7. Marquees and duplicated tracks
|
|
164
|
-
|
|
165
|
-
Render the track once with fields, and mark every copy:
|
|
166
|
-
|
|
167
|
-
```astro
|
|
168
|
-
<div class="marquee">
|
|
169
|
-
<ul class="track">{logos.map((l, i) => <li class="logo" data-bcms-field={`logos[${i}].name`}>{l.name}</li>)}</ul>
|
|
170
|
-
<ul class="track" aria-hidden="true" data-bcms-clone>{logos.map((l) => <li class="logo">{l.name}</li>)}</ul>
|
|
171
|
-
</div>
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
A copy under `aria-hidden="true"` or `data-bcms-clone` is not counted as unowned text, and a
|
|
175
|
-
publish writes it with the original when it is the same tag with the same classes and the same
|
|
176
|
-
text. A copy with neither marker is separate copy: it counts against the route's text coverage
|
|
177
|
-
and keeps the old words. A script that clones the track in the browser needs nothing: the clone
|
|
178
|
-
is made from the published HTML on every load.
|
|
179
|
-
|
|
180
|
-
Check: publish one logo name, then `document.querySelectorAll(".track")` shows the new name in
|
|
181
|
-
every track.
|
|
182
|
-
|
|
183
|
-
## 8. Swiper and carousels
|
|
184
|
-
|
|
185
|
-
Bind what is inside a slide, never `.swiper-wrapper` or `.swiper-slide`:
|
|
186
|
-
|
|
187
|
-
```html
|
|
188
|
-
<div class="swiper-slide"><h3 data-bcms-field="slides[0].title">Torque</h3></div>
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
Loop mode clones slides in the browser (`.swiper-slide-duplicate`); those are made from the
|
|
192
|
-
published HTML and need no field. On `bcms:content-applied`, call `swiper.update()`.
|
|
193
|
-
|
|
194
|
-
Check: `get_binding_report` lists no `slides[n]` path as pending for the route.
|
|
195
|
-
|
|
196
|
-
## 9. Hydrated islands and `client:only`
|
|
197
|
-
|
|
198
|
-
A hydrated component draws itself again from the props the build serialized. Keep the text it
|
|
199
|
-
shows equal to one string prop, whole:
|
|
200
|
-
|
|
201
|
-
```astro
|
|
202
|
-
<Hero client:load title={page.hero.title} />
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
```tsx
|
|
206
|
-
export default function Hero({ title }) { return <h1 data-bcms-field="hero.title">{title}</h1>; }
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
A publish then rewrites that prop with the text. It cannot when the component builds the text
|
|
210
|
-
(`${title}!`, `title.toUpperCase()`, a string literal in the component), or when the props hold
|
|
211
|
-
the same words more than once, under a key such as `id` or `slug`, or as `__html`: the publish reports
|
|
212
|
-
`ISLAND_PROPS_UNMATCHED` and the value goes live with the next build. Content passed as a
|
|
213
|
-
slot is plain HTML and needs no prop.
|
|
214
|
-
|
|
215
|
-
`client:only` renders nothing at build time, so the built page has no copy to own. The release
|
|
216
|
-
names every such island on its route and the setup gate lists it as a `client-only-island`
|
|
217
|
-
exception by itself. To make the copy editable, change the directive to `client:load` or
|
|
218
|
-
`client:visible`, or pass the copy in as a slot.
|
|
219
|
-
|
|
220
|
-
Check: view source on the built route, find the `astro-island`, and its `props` attribute holds
|
|
221
|
-
the published title after a publish.
|
|
222
|
-
|
|
223
|
-
## 10. `<BcmsField>` adds no wrapper around one element
|
|
224
|
-
|
|
225
|
-
```astro
|
|
226
|
-
<BcmsField path="hero.title"><h1 class="title">{page.hero.title}</h1></BcmsField>
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
renders `<h1 class="title" data-bcms-field="hero.title">`: nothing sits between the parent and
|
|
230
|
-
the heading. Text, several children, and in Next a component child are still wrapped in a
|
|
231
|
-
`display:contents` span, and `as="div"` still gives a real wrapper. A site built with an older
|
|
232
|
-
package version keeps its wrapper spans until it is rebuilt.
|
|
233
|
-
|
|
234
|
-
`<BcmsSections>` follows the same rule for a whole section: the block id, the style marker and
|
|
235
|
-
the block's style go on the section's own root, and in Astro the list is delimited by two HTML
|
|
236
|
-
comments, never an element. `main > section`, `:first-child` and a ScrollTrigger on `.hero` match
|
|
237
|
-
what they matched before. The one exception is a section that is not one element (several roots,
|
|
238
|
-
an island, in Next a component child with no function form): it gets a box of ours, a node
|
|
239
|
-
between `main` and the section. With no style on the block the box is `display:contents` and
|
|
240
|
-
draws nothing. Once the block has a style the box is `display:flow-root` and carries it: one
|
|
241
|
-
block box, so in a flex or grid parent its children are one item instead of several, and their
|
|
242
|
-
margins stop collapsing with the neighbours'. Give such a section one root element, or the
|
|
243
|
-
function form ([next.md](next.md)), when a selector or the layout depends on it.
|
|
244
|
-
|
|
245
|
-
## 11. Lottie, canvas, WebGL, video
|
|
246
|
-
|
|
247
|
-
Pixels drawn by a script are not in the HTML, so nothing in them is a field by itself. What is
|
|
248
|
-
editable is what you pass in:
|
|
249
|
-
|
|
250
|
-
| Drawn by | Make editable by |
|
|
251
|
-
|---|---|
|
|
252
|
-
| Lottie | Bind the animation file as an image-or-file field on the container and load `path` from it. For words inside the animation, keep a text layer named after the field and set it at load (`anim.renderer.elements[i].updateDocumentData({ t: value })`), reading the value from a hidden bound element or a prop. |
|
|
253
|
-
| Canvas / WebGL / three.js | Read every string and texture URL from `data-*` attributes or props on the canvas's container; bind a visually hidden element per string and an `<img hidden>` per texture, and re-read them on `bcms:content-applied`. |
|
|
254
|
-
| Video | The poster and the source are fields on the `<video>`; burned-in captions are not text. |
|
|
255
|
-
|
|
256
|
-
Give the canvas `role="img"` and an `aria-label` bound to a field, or an adjacent caption. A
|
|
257
|
-
scene with no text a visitor reads needs no field; record it under the `decorative` exception.
|
|
258
|
-
|
|
259
|
-
A canvas drawn over the page (a fixed full-screen scene behind or above the copy) does not block
|
|
260
|
-
the editor: a click looks through an element that is no field and no control to the field under
|
|
261
|
-
it. Keep the copy in the HTML above or below the canvas, never only inside the scene.
|
|
262
|
-
|
|
263
|
-
Check: change the bound string, publish, reload: the drawn text reads the new value.
|
|
264
|
-
|
|
265
|
-
## 12. In the editor's canvas
|
|
266
|
-
|
|
267
|
-
The Visual Editor shows the real page with its scripts running, and owns three things there: the
|
|
268
|
-
scroll (the page scrolls its own window, as it does live), the pointer (a click selects a field)
|
|
269
|
-
and the place on the page (picking a field in the panel scrolls to it). Motion that reads the
|
|
270
|
-
window's scroll or the clock runs there as it does live. Only what takes one of those three away
|
|
271
|
-
has to stand down, and the site decides that with one question:
|
|
272
|
-
|
|
273
|
-
```js
|
|
274
|
-
import { inEditor } from "@bettercms-ai/sdk"; // 1.31+. Without the SDK: window.name === "bcms-editor"
|
|
275
|
-
|
|
276
|
-
const smooth = inEditor() ? null : new LocomotiveScroll({ el, smooth: true });
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
`inEditor()` is true from the page's first script and through every navigation inside the
|
|
280
|
-
canvas, and false on the published page. What each kind of page does there:
|
|
281
|
-
|
|
282
|
-
| The page uses | In the canvas | You write |
|
|
283
|
-
|---|---|---|
|
|
284
|
-
| Native scroll, CSS scroll-driven animations (`animation-timeline`), Motion `useScroll` / `whileInView`, ScrollTrigger pin and scrub | runs as live | nothing |
|
|
285
|
-
| Lenis, Locomotive Scroll 5 | runs; the wheel has no easing (section 6) | nothing |
|
|
286
|
-
| GSAP ScrollSmoother | runs; the window still scrolls, the content eases behind it | nothing |
|
|
287
|
-
| Locomotive Scroll 4, any script that locks the window and moves the content by a transform | the page works under its own wheel, but the editor cannot scroll it and a field picked in the panel is not brought into view | `smooth: !inEditor()`, so the window scrolls |
|
|
288
|
-
| A wheel-driven slide deck (GSAP `Observer`, fullPage) | the same | do not create the observer when `inEditor()`; let the sections stack and scroll |
|
|
289
|
-
| A scene with its own scroll layer (react-three-fiber `ScrollControls`, a fixed `overflow: auto` layer) | runs; picking a field scrolls that layer | nothing |
|
|
290
|
-
| An intro or preloader that holds the page (`overflow: hidden` until it ends, a full-screen cover) | plays on every reload of the canvas, and covers the page while it does | skip it when `inEditor()` |
|
|
291
|
-
|
|
292
|
-
Never use `inEditor()` to change what a field renders, to hide a section or to stop a timeline:
|
|
293
|
-
the canvas must show what a visitor gets. It is for scroll takeover, intros and nothing else.
|
|
294
|
-
|
|
295
|
-
Check: open the page in the Visual Editor, scroll with the wheel, drag the page's scrollbar to the
|
|
296
|
-
footer, then pick a field of the first section in the panel: the canvas returns to it.
|
|
1
|
+
---
|
|
2
|
+
title: "Animated sites"
|
|
3
|
+
description: "Keeping GSAP, ScrollTrigger, SplitText, Framer Motion, Lenis, marquees, carousels, Lottie and hydrated islands working while their copy stays editable and publishable."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Animated sites: editable copy that keeps moving
|
|
7
|
+
|
|
8
|
+
Read this when `package.json` lists `gsap`, `lenis`, `locomotive-scroll`, `framer-motion` or
|
|
9
|
+
`motion`, `swiper`, `split-type`, `splitting`, `lottie-web`, `three` or `@react-three/fiber`, or
|
|
10
|
+
when the built HTML carries
|
|
11
|
+
`astro-island`, split-text spans or a duplicated marquee track. Attribute grammar:
|
|
12
|
+
[bindings.md](bindings.md). Adapter wiring: [frameworks.md](frameworks.md).
|
|
13
|
+
|
|
14
|
+
## 1. What a publish does to animated markup
|
|
15
|
+
|
|
16
|
+
The bound element picks the writer. One that holds text and nothing else is replaced whole, as
|
|
17
|
+
it always was. One that holds markup (inline elements, split spans, a `<br>`, or
|
|
18
|
+
`data-bcms-split`) is written into its text nodes, and every element inside it is kept. That is
|
|
19
|
+
what the recipes below rely on:
|
|
20
|
+
|
|
21
|
+
| The page has | A publish does |
|
|
22
|
+
|---|---|
|
|
23
|
+
| Inline markup inside the bound element (`<em>`, a highlight `<span>`, `<br>`) | changes the words, keeps the elements and their attributes; a bare `<em>` or `<span>` the new words leave empty is removed |
|
|
24
|
+
| Text cut into `word` / `char` / `line` spans, same word count | writes each span in place |
|
|
25
|
+
| The same, word count changed, spans made by a library at runtime | puts the plain text back; the library splits it on its next run |
|
|
26
|
+
| The same, spans written by your own template | rebuilds them from their own pattern, a new one copied from the last |
|
|
27
|
+
| A split headline under an `aria-label` that says the same words (SplitText's own output) | one field on the headline: writes the label and the words |
|
|
28
|
+
| A counter with its number in `data-target`, `data-count` or a like attribute | writes the attribute; writes the number shown only where it showed the old target, never a script's starting `0` |
|
|
29
|
+
| A copy of the value under `aria-hidden="true"` or `data-bcms-clone` | writes the copy too (coverage kind `duplicate-track`) |
|
|
30
|
+
| The value inside `astro-island` props, `__NEXT_DATA__` or the app router's flight scripts | rewrites the serialized string (coverage kind `island-props`) |
|
|
31
|
+
|
|
32
|
+
It refuses, and says so on the publish, when the change would have to remove something:
|
|
33
|
+
`TEXT_IN_CHILD_FIELD` (the words belong to a field declared inside this one),
|
|
34
|
+
`TEXT_EMPTIES_ELEMENT` (a link, a scripted element or a wrapper with attributes would be left empty),
|
|
35
|
+
`SPLIT_PATTERN_UNKNOWN` (split markup that is not one row of like tokens) and
|
|
36
|
+
`ISLAND_PROPS_UNMATCHED` (a hydrated island draws the text from props that do not hold it).
|
|
37
|
+
Each is fixed in the template with the recipe for its library.
|
|
38
|
+
|
|
39
|
+
The last two rows follow a value only where the element was written into its text nodes. An
|
|
40
|
+
operator can switch a project, or the fleet, to write every text value that way, so a plain-text
|
|
41
|
+
value's copies and props follow it too; and can switch the text-node writer off, after which a
|
|
42
|
+
publish replaces an element's content with plain text and refuses what that would break.
|
|
43
|
+
|
|
44
|
+
## 2. Two rules for every library
|
|
45
|
+
|
|
46
|
+
1. **Bind the element that holds the words, never the element the animation owns.** A pinned
|
|
47
|
+
section, a scroll wrapper, a slider track and a motion container are layout. Their text
|
|
48
|
+
leaves are the fields.
|
|
49
|
+
2. **Refresh measurements after an edit.** The editor posts `{ type: "bcms:content-applied" }`
|
|
50
|
+
to the page's window after it applies an edit. Longer copy changes heights, so anything that
|
|
51
|
+
measured the page must measure again:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
window.addEventListener("message", (event) => {
|
|
55
|
+
if (event.data?.type !== "bcms:content-applied") return;
|
|
56
|
+
ScrollTrigger.refresh(); // or lenis.resize(), swiper.update(), split.revert() then split again
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`onContentApplied(handler)` from `@bettercms-ai/sdk` is the same listener with the check
|
|
61
|
+
written for you. On a published page nothing posts the message and the listener never runs.
|
|
62
|
+
|
|
63
|
+
## 3. GSAP and ScrollTrigger
|
|
64
|
+
|
|
65
|
+
```html
|
|
66
|
+
<section class="pin-wrap"> <!-- trigger and pin: no field -->
|
|
67
|
+
<div class="pin">
|
|
68
|
+
<h2 data-bcms-field="story.title">Built to last</h2>
|
|
69
|
+
<p data-bcms-field="story.lead">Forged, not cast.</p>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
gsap.to(".pin h2", { yPercent: -20, scrollTrigger: { trigger: ".pin-wrap", pin: ".pin", scrub: 1 } });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Never put `data-bcms-field` on `.pin-wrap` or `.pin`: ScrollTrigger wraps a pinned element in
|
|
79
|
+
a spacer and writes inline styles on it every frame. Use `<BcmsField>` only around a single
|
|
80
|
+
element, where it adds no wrapper (section 10); a wrapper span between `.pin` and its `h2`
|
|
81
|
+
would break `.pin > h2`.
|
|
82
|
+
|
|
83
|
+
Check: with the page open, `ScrollTrigger.getAll().length` is the same before and after a
|
|
84
|
+
publish of every field on the route.
|
|
85
|
+
|
|
86
|
+
## 4. SplitText, SplitType, Splitting.js
|
|
87
|
+
|
|
88
|
+
Bind the parent and say that it is split. Never bind a word or a character.
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<h1 class="js-split" data-bcms-field="hero.title" data-bcms-split="words">Fast screws, shipped</h1>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
let split = new SplitText(".js-split", { type: "words" });
|
|
96
|
+
window.addEventListener("message", (event) => {
|
|
97
|
+
if (event.data?.type !== "bcms:content-applied") return;
|
|
98
|
+
split.revert();
|
|
99
|
+
split = new SplitText(".js-split", { type: "words" });
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`data-bcms-split` takes `chars`, `words` or `lines`. It tells the editor that the spans under
|
|
104
|
+
this element are one value, and it tells a publish that the children are tokens even when their
|
|
105
|
+
class is not `word`, `char` or `line`.
|
|
106
|
+
|
|
107
|
+
When your own template writes the spans (`title.split(" ").map(...)`), keep them one row of
|
|
108
|
+
the same tag, with whitespace or nothing between them, and no other element in the row:
|
|
109
|
+
|
|
110
|
+
```astro
|
|
111
|
+
<h1 data-bcms-field="hero.title" data-bcms-split="words">
|
|
112
|
+
{title.split(" ").map((word) => <span class="word">{word}</span>)}
|
|
113
|
+
</h1>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A `<br>` between two word spans makes the row unreadable once the word count changes
|
|
117
|
+
(`SPLIT_PATTERN_UNKNOWN`): put the break in CSS, or split by lines.
|
|
118
|
+
|
|
119
|
+
Check: publish a title with one more word, reload, and count `.word`: it equals the new word
|
|
120
|
+
count and no word sits outside a span.
|
|
121
|
+
|
|
122
|
+
## 5. Framer Motion
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
<motion.section initial={{ opacity: 0 }} whileInView={{ opacity: 1 }}> {/* no field */}
|
|
126
|
+
<motion.h2 data-bcms-field="story.title" variants={rise}>{story.title}</motion.h2>
|
|
127
|
+
<p data-bcms-field="story.lead">{story.lead}</p>
|
|
128
|
+
</motion.section>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Put `data-bcms-field` straight on the motion element that renders the text: it passes
|
|
132
|
+
`data-*` through to the DOM. Do not wrap a motion component in `<BcmsField>`; a component child
|
|
133
|
+
keeps the wrapper span, and `layout` animations measure the wrong box. For words animated one
|
|
134
|
+
by one, map them to `motion.span` under a parent carrying `data-bcms-split="words"`, as in
|
|
135
|
+
section 4. The component hydrates, so section 9 applies to its props.
|
|
136
|
+
|
|
137
|
+
Check: the built HTML for the route shows `data-bcms-field` on the `h2` itself, with no
|
|
138
|
+
`display:contents` span around it.
|
|
139
|
+
|
|
140
|
+
## 6. Lenis and other smooth scroll
|
|
141
|
+
|
|
142
|
+
Lenis moves the page, not the content, so no binding changes. Two things do:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
const lenis = new Lenis();
|
|
146
|
+
window.addEventListener("message", (event) => {
|
|
147
|
+
if (event.data?.type === "bcms:content-applied") lenis.resize();
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
and any panel of yours that must scroll by itself inside the page takes `data-lenis-prevent`.
|
|
152
|
+
Never bind the element Lenis is given as `wrapper` or `content`.
|
|
153
|
+
|
|
154
|
+
In the Visual Editor the canvas owns the scroll: it marks the page's `<body>` with
|
|
155
|
+
`data-lenis-prevent`, so the wheel there is the browser's own and has no easing. That is the
|
|
156
|
+
editor, not a fault in the site: Lenis still reports every scroll (ScrollTrigger and anything
|
|
157
|
+
else hung on `lenis.on("scroll")` runs), its own `scrollTo` still eases, and the published page
|
|
158
|
+
is untouched. Do not remove the attribute or "repair" the easing for the canvas. What other
|
|
159
|
+
scroll libraries need there is in section 12.
|
|
160
|
+
|
|
161
|
+
Check: after an edit that adds two lines to a paragraph, the page still scrolls to the footer.
|
|
162
|
+
|
|
163
|
+
## 7. Marquees and duplicated tracks
|
|
164
|
+
|
|
165
|
+
Render the track once with fields, and mark every copy:
|
|
166
|
+
|
|
167
|
+
```astro
|
|
168
|
+
<div class="marquee">
|
|
169
|
+
<ul class="track">{logos.map((l, i) => <li class="logo" data-bcms-field={`logos[${i}].name`}>{l.name}</li>)}</ul>
|
|
170
|
+
<ul class="track" aria-hidden="true" data-bcms-clone>{logos.map((l) => <li class="logo">{l.name}</li>)}</ul>
|
|
171
|
+
</div>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
A copy under `aria-hidden="true"` or `data-bcms-clone` is not counted as unowned text, and a
|
|
175
|
+
publish writes it with the original when it is the same tag with the same classes and the same
|
|
176
|
+
text. A copy with neither marker is separate copy: it counts against the route's text coverage
|
|
177
|
+
and keeps the old words. A script that clones the track in the browser needs nothing: the clone
|
|
178
|
+
is made from the published HTML on every load.
|
|
179
|
+
|
|
180
|
+
Check: publish one logo name, then `document.querySelectorAll(".track")` shows the new name in
|
|
181
|
+
every track.
|
|
182
|
+
|
|
183
|
+
## 8. Swiper and carousels
|
|
184
|
+
|
|
185
|
+
Bind what is inside a slide, never `.swiper-wrapper` or `.swiper-slide`:
|
|
186
|
+
|
|
187
|
+
```html
|
|
188
|
+
<div class="swiper-slide"><h3 data-bcms-field="slides[0].title">Torque</h3></div>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Loop mode clones slides in the browser (`.swiper-slide-duplicate`); those are made from the
|
|
192
|
+
published HTML and need no field. On `bcms:content-applied`, call `swiper.update()`.
|
|
193
|
+
|
|
194
|
+
Check: `get_binding_report` lists no `slides[n]` path as pending for the route.
|
|
195
|
+
|
|
196
|
+
## 9. Hydrated islands and `client:only`
|
|
197
|
+
|
|
198
|
+
A hydrated component draws itself again from the props the build serialized. Keep the text it
|
|
199
|
+
shows equal to one string prop, whole:
|
|
200
|
+
|
|
201
|
+
```astro
|
|
202
|
+
<Hero client:load title={page.hero.title} />
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
export default function Hero({ title }) { return <h1 data-bcms-field="hero.title">{title}</h1>; }
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
A publish then rewrites that prop with the text. It cannot when the component builds the text
|
|
210
|
+
(`${title}!`, `title.toUpperCase()`, a string literal in the component), or when the props hold
|
|
211
|
+
the same words more than once, under a key such as `id` or `slug`, or as `__html`: the publish reports
|
|
212
|
+
`ISLAND_PROPS_UNMATCHED` and the value goes live with the next build. Content passed as a
|
|
213
|
+
slot is plain HTML and needs no prop.
|
|
214
|
+
|
|
215
|
+
`client:only` renders nothing at build time, so the built page has no copy to own. The release
|
|
216
|
+
names every such island on its route and the setup gate lists it as a `client-only-island`
|
|
217
|
+
exception by itself. To make the copy editable, change the directive to `client:load` or
|
|
218
|
+
`client:visible`, or pass the copy in as a slot.
|
|
219
|
+
|
|
220
|
+
Check: view source on the built route, find the `astro-island`, and its `props` attribute holds
|
|
221
|
+
the published title after a publish.
|
|
222
|
+
|
|
223
|
+
## 10. `<BcmsField>` adds no wrapper around one element
|
|
224
|
+
|
|
225
|
+
```astro
|
|
226
|
+
<BcmsField path="hero.title"><h1 class="title">{page.hero.title}</h1></BcmsField>
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
renders `<h1 class="title" data-bcms-field="hero.title">`: nothing sits between the parent and
|
|
230
|
+
the heading. Text, several children, and in Next a component child are still wrapped in a
|
|
231
|
+
`display:contents` span, and `as="div"` still gives a real wrapper. A site built with an older
|
|
232
|
+
package version keeps its wrapper spans until it is rebuilt.
|
|
233
|
+
|
|
234
|
+
`<BcmsSections>` follows the same rule for a whole section: the block id, the style marker and
|
|
235
|
+
the block's style go on the section's own root, and in Astro the list is delimited by two HTML
|
|
236
|
+
comments, never an element. `main > section`, `:first-child` and a ScrollTrigger on `.hero` match
|
|
237
|
+
what they matched before. The one exception is a section that is not one element (several roots,
|
|
238
|
+
an island, in Next a component child with no function form): it gets a box of ours, a node
|
|
239
|
+
between `main` and the section. With no style on the block the box is `display:contents` and
|
|
240
|
+
draws nothing. Once the block has a style the box is `display:flow-root` and carries it: one
|
|
241
|
+
block box, so in a flex or grid parent its children are one item instead of several, and their
|
|
242
|
+
margins stop collapsing with the neighbours'. Give such a section one root element, or the
|
|
243
|
+
function form ([next.md](next.md)), when a selector or the layout depends on it.
|
|
244
|
+
|
|
245
|
+
## 11. Lottie, canvas, WebGL, video
|
|
246
|
+
|
|
247
|
+
Pixels drawn by a script are not in the HTML, so nothing in them is a field by itself. What is
|
|
248
|
+
editable is what you pass in:
|
|
249
|
+
|
|
250
|
+
| Drawn by | Make editable by |
|
|
251
|
+
|---|---|
|
|
252
|
+
| Lottie | Bind the animation file as an image-or-file field on the container and load `path` from it. For words inside the animation, keep a text layer named after the field and set it at load (`anim.renderer.elements[i].updateDocumentData({ t: value })`), reading the value from a hidden bound element or a prop. |
|
|
253
|
+
| Canvas / WebGL / three.js | Read every string and texture URL from `data-*` attributes or props on the canvas's container; bind a visually hidden element per string and an `<img hidden>` per texture, and re-read them on `bcms:content-applied`. |
|
|
254
|
+
| Video | The poster and the source are fields on the `<video>`; burned-in captions are not text. |
|
|
255
|
+
|
|
256
|
+
Give the canvas `role="img"` and an `aria-label` bound to a field, or an adjacent caption. A
|
|
257
|
+
scene with no text a visitor reads needs no field; record it under the `decorative` exception.
|
|
258
|
+
|
|
259
|
+
A canvas drawn over the page (a fixed full-screen scene behind or above the copy) does not block
|
|
260
|
+
the editor: a click looks through an element that is no field and no control to the field under
|
|
261
|
+
it. Keep the copy in the HTML above or below the canvas, never only inside the scene.
|
|
262
|
+
|
|
263
|
+
Check: change the bound string, publish, reload: the drawn text reads the new value.
|
|
264
|
+
|
|
265
|
+
## 12. In the editor's canvas
|
|
266
|
+
|
|
267
|
+
The Visual Editor shows the real page with its scripts running, and owns three things there: the
|
|
268
|
+
scroll (the page scrolls its own window, as it does live), the pointer (a click selects a field)
|
|
269
|
+
and the place on the page (picking a field in the panel scrolls to it). Motion that reads the
|
|
270
|
+
window's scroll or the clock runs there as it does live. Only what takes one of those three away
|
|
271
|
+
has to stand down, and the site decides that with one question:
|
|
272
|
+
|
|
273
|
+
```js
|
|
274
|
+
import { inEditor } from "@bettercms-ai/sdk"; // 1.31+. Without the SDK: window.name === "bcms-editor"
|
|
275
|
+
|
|
276
|
+
const smooth = inEditor() ? null : new LocomotiveScroll({ el, smooth: true });
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`inEditor()` is true from the page's first script and through every navigation inside the
|
|
280
|
+
canvas, and false on the published page. What each kind of page does there:
|
|
281
|
+
|
|
282
|
+
| The page uses | In the canvas | You write |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| Native scroll, CSS scroll-driven animations (`animation-timeline`), Motion `useScroll` / `whileInView`, ScrollTrigger pin and scrub | runs as live | nothing |
|
|
285
|
+
| Lenis, Locomotive Scroll 5 | runs; the wheel has no easing (section 6) | nothing |
|
|
286
|
+
| GSAP ScrollSmoother | runs; the window still scrolls, the content eases behind it | nothing |
|
|
287
|
+
| Locomotive Scroll 4, any script that locks the window and moves the content by a transform | the page works under its own wheel, but the editor cannot scroll it and a field picked in the panel is not brought into view | `smooth: !inEditor()`, so the window scrolls |
|
|
288
|
+
| A wheel-driven slide deck (GSAP `Observer`, fullPage) | the same | do not create the observer when `inEditor()`; let the sections stack and scroll |
|
|
289
|
+
| A scene with its own scroll layer (react-three-fiber `ScrollControls`, a fixed `overflow: auto` layer) | runs; picking a field scrolls that layer | nothing |
|
|
290
|
+
| An intro or preloader that holds the page (`overflow: hidden` until it ends, a full-screen cover) | plays on every reload of the canvas, and covers the page while it does | skip it when `inEditor()` |
|
|
291
|
+
|
|
292
|
+
Never use `inEditor()` to change what a field renders, to hide a section or to stop a timeline:
|
|
293
|
+
the canvas must show what a visitor gets. It is for scroll takeover, intros and nothing else.
|
|
294
|
+
|
|
295
|
+
Check: open the page in the Visual Editor, scroll with the wheel, drag the page's scrollbar to the
|
|
296
|
+
footer, then pick a field of the first section in the panel: the canvas returns to it.
|