caracms 0.0.0-stage → 0.2.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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,376 @@
1
+ ---
2
+ name: caracms-blocks
3
+ description: Build or change a caracms site's code — blocks and their fields, collections (with or without pages), text styles and lockups, Site Settings fields, the page component, forms, and how the site's scripts (GSAP, Lenis, carousels) behave in the editor's live preview. Use when editing caracms.config.ts or the Astro components it names.
4
+ ---
5
+
6
+ # Building blocks and collections
7
+
8
+ `caracms.config.ts` is everything caracms knows about the site. It's plain data: fields
9
+ are made with helpers from `caracms/config`, and components are named by their path from
10
+ the site's root (strings, never imports). `astro.config` passes it to the integration
11
+ (`integrations: [caracms(site)]`). A mistake in it stops `astro dev` and `astro build`
12
+ with a sentence naming the problem.
13
+
14
+ ```ts
15
+ import { defineSite, list, richText, text } from 'caracms/config';
16
+
17
+ export default defineSite({
18
+ collections: {
19
+ pages: { kind: 'sections', folder: 'content/pages', urlBase: '/', nested: true },
20
+ },
21
+ blocks: {
22
+ faq: {
23
+ label: 'Questions',
24
+ component: 'src/components/caracms/Faq.astro',
25
+ fields: {
26
+ heading: text({ heading: true }),
27
+ items: list({ question: text(), answer: richText() }, { itemLabel: 'question' }),
28
+ },
29
+ },
30
+ },
31
+ textStyles: {},
32
+ page: 'src/components/caracms/Page.astro',
33
+ });
34
+ ```
35
+
36
+ A block's `type` in the content is its key here, so **never rename a block key or a field
37
+ key** once content uses it: the content would stop matching. Add new ones instead.
38
+
39
+ After changing the config, run `npx caracms schemas` (or `validate --fix`) so `schemas/`
40
+ and the content's `$schema` keys match, and commit `schemas/` with the change.
41
+ `astro dev` rewrites the schema files when it starts or restarts, but never touches
42
+ content.
43
+
44
+ ## Fields
45
+
46
+ All from `caracms/config`. Every one takes `label` (default: the key, title-cased) and
47
+ `help` (a line under the field in the editor).
48
+
49
+ | Helper | Options | The component gets |
50
+ |---|---|---|
51
+ | `text()` | `multiline`, `heading` (it can hold the page's `h1`), `max` (a soft limit) | `string` |
52
+ | `lockup({ style })` | `style`: a key of `textStyles`; `heading` (default true) | `{ lines, phone }` |
53
+ | `select({ options })` | `options`: strings or `{ value, label }`; `default` (else the first) | the value |
54
+ | `richText()` | `headings: ['h2']` or `['h2', 'h3']` (default none); `links` (default true) | rich text, for `<RichText>` |
55
+ | `link()` | `addressOnly: true` for an address with no words | `{ label, href }`, or `{ href }` / `null` |
56
+ | `buttons({ styles })` | `styles` (strings or `{ value, label }`), `max` (default 2), `allowNoLink` | `[{ label, href, style }]` |
57
+ | `toggle()` | `default` | `boolean` |
58
+ | `number()` | `min`, `max`, `step` | `number \| null` |
59
+ | `date()` | | `"YYYY-MM-DD"` or `null` |
60
+ | `media()` | `only: 'image'` or `'video'`; `play: 'loop'` | for `<Media>` |
61
+ | `reference({ to })` | `to`: a collection's key | the document's path, or `null` if it's gone |
62
+ | `list(field)` / `list({ … })` | `itemLabel`, `min`, `max` (warned, never enforced) | an array |
63
+ | `group({ … })` | | one object |
64
+
65
+ - A list of several fields may hold one list among its fields; that inner list can't hold
66
+ a list. A group can't hold a group.
67
+ - Type a component's data from the schema:
68
+ `export type FaqData = BlockData<typeof faq.fields>;` (keep the block's config in a
69
+ `.ts` beside its component and import it into `caracms.config.ts`).
70
+
71
+ ## A block component
72
+
73
+ ```astro
74
+ ---
75
+ import Block from 'caracms/components/Block.astro';
76
+ import RichText from 'caracms/components/RichText.astro';
77
+ import Text from 'caracms/components/Text.astro';
78
+ import type { BlockProps } from 'caracms/document';
79
+ import type { FaqData } from './faq.ts';
80
+
81
+ type Props = BlockProps<FaqData>;
82
+ const { block, cms } = Astro.props;
83
+ ---
84
+
85
+ <Block cms={cms} class="faq">
86
+ <Text cms={cms} field="heading" value={block.heading} class="faq-title" />
87
+ {block.items.map((item, i) => (
88
+ <details>
89
+ <summary><Text cms={cms} field={`items.${i}.question`} value={item.question} as="h3" /></summary>
90
+ <RichText cms={cms} field={`items.${i}.answer`} value={item.answer} class="prose" />
91
+ </details>
92
+ ))}
93
+ </Block>
94
+ ```
95
+
96
+ - **Wrap the block in `<Block cms={cms}>`** (`as` sets the element, default `section`;
97
+ `class` and other attributes pass through). In the editor's preview it marks the block
98
+ so it can be selected.
99
+ - **Attributes pass through as on a plain element**, on `Block`, `Text`, `RichText`,
100
+ `Lockup`, `Buttons`, `Link` and `Media`: a bare `data-hero` renders bare,
101
+ `hidden={false}` renders nothing. `aria-*`, `draggable`, `contenteditable`,
102
+ `spellcheck` and `writingsuggestions` keep their `"true"` / `"false"` values.
103
+ - **Whitespace in a slot**: Astro's compiler drops the space between an expression and an
104
+ element in any component's slot, so `<Link …>{label} <span>→</span></Link>` loses it.
105
+ Write `{label}{" "}<span>→</span>`.
106
+ - **Draw every field through the package's component for its kind**, with `field` (its
107
+ key, or a dotted path into a list: `items.0.question`) and `value`. In the preview they
108
+ carry the markers that make text editable on the page; on the public page they're plain
109
+ markup. Each takes `class` and `as` (the element) and passes other attributes on.
110
+ - `Text`: a `<p>`, or `h1`/`h2` for a `heading: true` field (the page's first heading is
111
+ the `h1`). `as` overrides that. It always renders its element, since the editor
112
+ needs one to edit, so text that was a bare text node gains one: use the element the
113
+ old markup had where it had one (`as="span"` inside a sentence).
114
+ - `Lockup`: a lockup in its text style, fitted to its box.
115
+ - `RichText`: a `<div>` of the rich text's HTML.
116
+ - `Link`: an `<a>` holding the label (or what you put inside `<Link>`); a link with no
117
+ label or no address draws nothing. An address-only link wraps what's inside it (an
118
+ image, an icon). An address-only link's value is `{ href }`, or `null` when it's
119
+ empty or isn't a valid address; handle `null` in your own markup (draw no link), never
120
+ fall back to `href=""`. A valid address is a `/path`, `https://`, `mailto:`, `tel:`,
121
+ `#id`, or `#` alone.
122
+ - `Buttons`: the buttons, each with `buttonClass` and `<buttonClass>--<style>`.
123
+ - `Media`: exactly one `<img>` (or the clip). Pass `sizes` (and optionally `ratio`,
124
+ width / height) for a fluid image, or a CSS `width` and/or `height` for a fixed one;
125
+ `fit: 'contain'` to fit instead of crop. The block's CSS sizes it through `class`.
126
+ It takes the element's own attributes (`id`, `title`, `draggable`, `aria-*`,
127
+ `data-*`) too. With an empty value it draws nothing for visitors and, in a preview,
128
+ a dashed placeholder the editor can click to pick the image. So call it as
129
+ `(block.media || cms.preview) && <Media …/>`, not `block.media && …`, wherever an
130
+ empty slot should still be reachable on the page.
131
+ - **A media value reaches the block resolved, not as stored.** Where the file has
132
+ `{ key, alt, focal, w, h }` or `{ video, play, poster: 12.5 }`, the block gets
133
+ `{ kind: 'image', key, alt, decorative, focal, position, w, h }` or
134
+ `{ kind: 'video', playbackId, play, name, mp4, poster: <url>, duration, w, h }`
135
+ (`poster` is Mux's still as a URL, `w`, `h` and `duration` are numbers or `null`;
136
+ `block.media` is `null` when the slot is empty). A site helper that reads a media
137
+ value (a hero's poster, a video's `playbackId`) reads this shape; use `kind` to tell
138
+ the two apart.
139
+ - `cms` is the render context: `cms.preview` (a preview render), `cms.first`,
140
+ `cms.index`, `cms.read` (other documents, below). For "the first block on the page"
141
+ (an eager hero, a heading that opens the page) use `cms.first`, true for the first
142
+ block that renders. Not `cms.index === 0`: `index` is the entry's place in the
143
+ document's `blocks`, counting entries that don't render (an unknown type, a shared block
144
+ that's gone). `headingTag(cms, 'heading')` from
145
+ `caracms/document` gives `'h1'` or `'h2'` for markup of your own.
146
+ - In a preview, draw empty fields too (`cms.preview || block.heading`), so an editor can
147
+ click into them.
148
+
149
+ ## Collections
150
+
151
+ ```ts
152
+ // A collection with pages of its own, at /work/<slug>
153
+ work: { kind: 'fields', folder: 'content/work', urlBase: '/work/', component: 'src/components/caracms/CaseStudy.astro', fields: { … } },
154
+ // A collection with no pages (a team, an FAQ): leave out urlBase
155
+ people: { kind: 'fields', folder: 'content/people', component: 'src/components/caracms/Person.astro', fields: { role: text(), photo: media({ only: 'image' }) } },
156
+ // A second section builder (a journal)
157
+ journal: { kind: 'sections', folder: 'content/journal', urlBase: '/journal/', fields: { date: date() }, blocks: ['text', 'image'] },
158
+ ```
159
+
160
+ - A `fields` collection's `component` renders one document: it gets
161
+ `{ document, cms }` (`FieldsDocumentProps` from `caracms/document`), with
162
+ `document.title` and each field's value; draw fields with `field="<key>"`. The page
163
+ component's `<Blocks>` renders it. For a pageless collection that's only the editor's
164
+ preview of one document.
165
+ - `title` is every document's own; no field can be called `title` (or, on a `sections`
166
+ collection, `blocks` or `seo`).
167
+ - Options: `label`, `columns` and `sort` (the editor's table), `view`
168
+ (`{ default: 'list' | 'grid' }`), and on a `sections` collection `fields` (page fields)
169
+ and `blocks` (the block types its pages may use).
170
+ - **Show a pageless collection** from a block: `await cms.read.documents('people')` gives
171
+ every document as `{ path, title, data }` (`data` is its fields' values, as a component
172
+ gets them), **ordered by title**. For another order, give the documents a `number()`
173
+ field and sort in the component, or let a page pick and order them with
174
+ `list(reference({ to: 'people' }))`. `cms.read.document('people', path)` reads one.
175
+ - The collection's route: a collection with a `urlBase` needs a route in `src/pages/` whose
176
+ static part is that `urlBase` (`src/pages/work/[slug].astro`, rendering with
177
+ `RenderDocument` from `caracms/components/RenderDocument.astro` and `listDocuments` /
178
+ `readDocument` from `caracms/content`, as `src/pages/[...path].astro` does for pages).
179
+ Its `getStaticPaths` leaves out entries with a `warning` (a file that can't be opened) and
180
+ `hidden` ones (a page hidden from the site, or one under it): those answer the 404 page.
181
+ Every other route in `src/pages/` is the site's own code, and its address can't hold a
182
+ document.
183
+ - Shared blocks (`content/shared/`) and Site Settings are built in; no config.
184
+
185
+ ## Site Settings fields
186
+
187
+ ```ts
188
+ settings: { fields: { menu: list({ label: text(), href: link({ addressOnly: true }) }), phone: text() } },
189
+ ```
190
+
191
+ They're edited under Site Settings and saved in `content/site-settings.json`, beside the
192
+ package's own keys (`title`, `editors`, `brand`, `seo`, `googleTag`, `formRecipients`,
193
+ `redirects`, which a field can't be called). The page component gets them as
194
+ `Astro.props.settings` (plus `settings.brand.name`): the header, the footer and other
195
+ parts of the page around the blocks read them there. A preview shows the draft.
196
+
197
+ The site's own `.astro` routes get the same object from `siteSettings`, so a header and
198
+ footer shared with them read one source:
199
+
200
+ ```astro
201
+ ---
202
+ import { siteSettings } from 'caracms/content';
203
+ import { site } from 'virtual:caracms/config';
204
+ const settings = await siteSettings(site, Astro.locals);
205
+ ---
206
+ ```
207
+
208
+ **A block that needs Site Settings** (a form block showing the site's phone number) reads
209
+ them the same way, with `siteSettings(site, Astro.locals)`: in a preview it reads the
210
+ editor's draft, so an edit to Site Settings shows in the block too. `cms.read` is for
211
+ collections only; it doesn't reach Site Settings.
212
+
213
+ ## Text styles and lockups
214
+
215
+ A lockup is a heading whose lines are set by the editor and fitted to their box in a
216
+ text style. A style, in `textStyles`:
217
+
218
+ ```ts
219
+ display: {
220
+ family: 'Site Display', cssVariable: '--font-display',
221
+ fontFile: { measure: 'src/fonts/Display.ttf', web: 'src/fonts/Display.woff2' },
222
+ weightRange: '400 700', axes: { wght: 600 },
223
+ sizePx: 96, minSizePx: 40, trackingEm: -0.02, leading: 1, textTransform: 'none',
224
+ },
225
+ ```
226
+
227
+ `measure` must be a TTF or OTF; `axes` pins every variable axis (`{}` for a static font).
228
+ `<Fonts />` in the page's `<head>` loads the styles' fonts. `phoneLinesWidth` (default
229
+ 640) is where a lockup's phone lines take over. A plain heading that doesn't need fitting
230
+ is simpler as `text({ heading: true })` in the site's own CSS.
231
+
232
+ ## The page component
233
+
234
+ The config's `page` renders a whole page from a document, for the public site and the
235
+ editor's preview alike. It gets `PageProps` (`caracms/document`): `document`,
236
+ `collection`, `path`, `preview`, `data` (the document's own fields), `settings`,
237
+ `listPage`, `read`.
238
+
239
+ ```astro
240
+ <html lang="en" data-preview={preview ? '' : undefined}>
241
+ <head>
242
+ <Head document={document} collection={collection} path={path} preview={preview} listPage={listPage} />
243
+ <Fonts />
244
+ </head>
245
+ <body>
246
+ <SiteHeader items={settings.menu} />
247
+ <main><Blocks document={document} collection={collection} preview={preview} listPage={listPage} /></main>
248
+ <SiteFooter />
249
+ </body>
250
+ </html>
251
+ ```
252
+
253
+ - `path` (`string | null`) is the document's address (`/`, `/about`,
254
+ `/work/harbour-coffee`), the same on the public page and in the editor's preview: no
255
+ trailing slash, not URL-encoded. Page 2 of a paged listing (`/journal/page/2`) gives its
256
+ document's `/journal` (`listPage` says which page). It's null when the document has no
257
+ page of its own (a shared block's preview, a collection without pages). Read it as
258
+ `const { path } = Astro.props`, never from `Astro.url` or `?path=`: in the preview those
259
+ are the editor's own address. Pass it on as `<Head path={path}>` (it's optional there);
260
+ `caracms init`'s page component does.
261
+ - `<Head>` (`caracms/components/Head.astro`) writes the title, description, canonical and
262
+ share tags. A page's title is its own `seo.title` as written; otherwise, on the home
263
+ page, Site Settings' `seo.title`, then the brand name; otherwise
264
+ `<document title> · <brand name>`. (A home title like `<brand> | <tagline>` goes in the
265
+ home page's own `seo.title`.) The ` · ` is the
266
+ config's `titleSeparator`, spaces included: `titleSeparator: ' | '` beside `page` in
267
+ `defineSite({ … })` keeps a site's old `About | Fernhill`. It's code, not a Site
268
+ Settings field: the title's format is design. `titleFallback: 'site'` beside it makes every
269
+ page without its own `seo.title` fall back as the home page does (Site Settings'
270
+ `seo.title`, then the brand name); the default is `'page-and-brand'`. A code route builds its title the same way
271
+ from `siteSettings`: `` `${title}${settings.seo.titleSeparator}${settings.brand.name}` ``. `<Blocks>` renders the blocks (or a fields document through its
272
+ collection's component).
273
+ - `document.kind === 'shared'` is a shared block's own preview: render it without the
274
+ header and footer.
275
+ - `<Media>` needs a block's `cms`, so images in the page's frame (a logo) stay the site's
276
+ own files.
277
+
278
+ ## Forms
279
+
280
+ ```ts
281
+ forms: {
282
+ contact: { label: 'Contact', email: { subject: 'New enquiry' }, fields: {
283
+ name: { label: 'Your name', autocomplete: 'name' },
284
+ email: { label: 'Email', type: 'email' },
285
+ message: { label: 'Message', type: 'textarea', optional: true },
286
+ } },
287
+ },
288
+ ```
289
+
290
+ Field `type`s: `text` (default), `email`, `tel`, `textarea`, `select` (with `options`),
291
+ `files`. A form's key is stored on every submission, so never rename it once it's live.
292
+ Draw it in a block with `<Form form="contact" preview={cms.preview} submit="Send" />`
293
+ (`caracms/components/Form.astro`); a block can offer a choice with
294
+ `select({ options: [{ value: 'contact', label: 'Contact' }] })`. Submissions land in the
295
+ editor's inbox and are emailed to Site Settings' `formRecipients` for that form.
296
+
297
+ A field can also take `help`, `placeholder` (text, email, tel, textarea), `prefix` (text,
298
+ tel), `error` (its own error line), `autocomplete`, and on a textarea `rows` (its height in
299
+ lines, 1 to 50; default 6).
300
+
301
+ The markup's classes are `.cms-form`, `.cms-form-field`, `.cms-form-label`,
302
+ `.cms-form-help`, `.cms-form-error`, `.cms-form-submit` and `.cms-form-status`. For a
303
+ site that draws its own look, `<Form>` takes, each optional (left out, it renders as
304
+ without them):
305
+
306
+ - `class`, and any other attribute (`data-reveal`, `id`, `aria-*`), on the `<form>`. The
307
+ form's own attributes (`action`, `method`, its `data-caracms-*`…) can't be replaced.
308
+ - `submitClass`: classes on the submit button, beside `cms-form-submit`
309
+ (`submitClass="btn btn-primary"`).
310
+ - `thanks`: the line after a send, in place of "Thanks, we'll be in touch shortly."
311
+ `failed`: the line when a send fails, in place of "Something went wrong. Please email
312
+ us directly." (when the endpoint gives its own sentence, that shows instead).
313
+ - `errors`: each field type's error line, in place of the package's wording:
314
+ `errors={{ email: 'Please check your email address.' }}`. A field's own `error` still wins.
315
+ - `actions`: wraps the button and the status line in `<div class="cms-form-actions">`, so
316
+ the site's CSS can set them in a row.
317
+ - `labelPlaceholders`: a field that can take a placeholder and sets none uses its label.
318
+
319
+ ```astro
320
+ <Form form="contact" preview={cms.preview} class="contact-form" data-reveal
321
+ submitClass="btn btn-primary" thanks="Thanks — we'll be in touch." actions labelPlaceholders />
322
+ ```
323
+
324
+ ## Scripts and the live preview
325
+
326
+ The editor shows the page in iframes, through the same page component with
327
+ `preview` on. When an editor changes something, the new render is **morphed into the
328
+ frame's `<body>`**: the page isn't reloaded, `<html>` and `<head>` stay, and the body's
329
+ elements are made to match the server's render (so classes and inline styles a script set
330
+ on them can be reset).
331
+
332
+ - **`<html data-preview>`**: set it in the page component from `preview` (as above), so
333
+ scripts can tell: `document.documentElement.hasAttribute('data-preview')`.
334
+ - **Opening another document reloads the frames**, so that page loads as on the public
335
+ site and its own module scripts run on arrival. Only an edit morphs.
336
+ - **`caracms:rendered`**: after each morph (an edit) the frame's `document` gets this
337
+ event; never on a load, which runs the scripts as usual, and never on the public site:
338
+
339
+ ```js
340
+ document.addEventListener('caracms:rendered', () => { /* pick up new elements, refresh */ });
341
+ ```
342
+
343
+ For motion (GSAP, ScrollTrigger, Lenis, Locomotive), the recipe that works:
344
+
345
+ 1. **Read the flag once**, when the site's motion starts:
346
+ `const preview = document.documentElement.hasAttribute('data-preview')`.
347
+ 2. **Leave the scroll native in the preview**, since the editor scrolls the frames to the
348
+ block being edited. With Lenis, keep the instance (other code may call it) but turn
349
+ wheel smoothing off: `new Lenis({ smoothWheel: !preview })`.
350
+ 3. **Show every animation in its finished state in the preview**, or an editor edits
351
+ blind: no intro, no entrance, nothing hidden until a scroll reveals it. If the site
352
+ already has a settled path (what it does on a back/forward visit, or under
353
+ `prefers-reduced-motion: reduce`), take that path whenever the preview loads (each
354
+ document opened is a fresh load). A head
355
+ script that plays an intro stays out of the preview altogether (the page component
356
+ leaves it out when `preview` is set).
357
+ 4. **On `caracms:rendered`, set up again**, on the settled path, then
358
+ `ScrollTrigger.refresh()`. Either set up only the elements that are new and drop
359
+ those that are gone, or, if every setup in the preview is settled, revert them all
360
+ (their GSAP contexts) and run them again: nothing replays, because nothing animates.
361
+ What jumps is rebuilding with the entrances on.
362
+
363
+ ```js
364
+ const preview = document.documentElement.hasAttribute('data-preview');
365
+ const lenis = new Lenis({ smoothWheel: !preview });
366
+ const settle = preview || matchMedia('(prefers-reduced-motion: reduce)').matches;
367
+ runSetups({ settled: settle }); // the site's own: each section's GSAP, in a gsap.context()
368
+ document.addEventListener('caracms:rendered', () => {
369
+ revertSetups();
370
+ runSetups({ settled: true });
371
+ ScrollTrigger.refresh();
372
+ });
373
+ ```
374
+
375
+ (`runSetups` and `revertSetups` stand for the site's own code; caracms provides only the
376
+ flag and the event.) With `prefers-reduced-motion: reduce`, show the end state too.
@@ -0,0 +1,201 @@
1
+ ---
2
+ name: caracms-content
3
+ description: Change a caracms site's content — the JSON files in content/ (pages, collection documents, shared blocks, Site Settings), rich text, images, references, adding an editor — and get it merged with a pull request after `caracms validate`. Use whenever a task adds, edits, moves or deletes content on a site that has caracms.config.ts.
4
+ ---
5
+
6
+ # Changing content on a caracms site
7
+
8
+ The site's content is JSON files in the repo. The site builds from them, and editors change
9
+ the same files through the editor at `/admin`. What a file may hold is set by
10
+ `caracms.config.ts`: its collections, blocks and fields. Read it first; it's the schema.
11
+
12
+ **Write files the way the editor saves them:** two-space JSON with a trailing newline, one
13
+ member per line, a string array, a media value and each rich-text block on one line of
14
+ their own. Otherwise the editor's next save reformats the whole file. `validate` warns on
15
+ a file that's laid out otherwise, and `validate --fix` rewrites it the editor's way
16
+ (whitespace only: every key stays where it is, every value as it is).
17
+
18
+ ## Where things are
19
+
20
+ - **Collections** are in `caracms.config.ts` → `collections`, each with a `folder`.
21
+ - `kind: 'sections'`: a page is a title and a list of blocks. With `nested: true`
22
+ (the usual `pages` collection at `urlBase: '/'`), the file's path is the address:
23
+ `content/pages/about.json` is `/about`, `content/pages/services/print.json` is
24
+ `/services/print`, and `content/pages/index.json` is Home. An `index.json` anywhere
25
+ deeper is an error: `/services` itself is `content/pages/services.json`.
26
+ - `kind: 'fields'`: each document is a title plus the collection's `fields`. With a
27
+ `urlBase` (`/work/`), `content/<folder>/<slug>.json` is `/work/<slug>`. With no
28
+ `urlBase` the documents have no page of their own (a team, an FAQ); other pages show
29
+ them.
30
+ - **Shared blocks** (one block used on several pages): `content/shared/<name>.json`
31
+ (or the config's `sharedFolder`).
32
+ - **Site Settings**: `content/site-settings.json`, one file.
33
+ - **Schemas**: `schemas/<collection>.schema.json`, `schemas/shared.schema.json`,
34
+ `schemas/site-settings.schema.json`, generated from the config. Every content file's
35
+ first key is `"$schema"`, the relative path to its schema
36
+ (`"../../schemas/pages.schema.json"`). Never edit `schemas/` by hand; `caracms schemas`
37
+ writes it.
38
+
39
+ File names are lower case words with hyphens (`about-us.json`), since they're addresses.
40
+
41
+ ## The file shapes
42
+
43
+ A page (`sections`):
44
+
45
+ ```json
46
+ {
47
+ "$schema": "../../schemas/pages.schema.json",
48
+ "title": "About",
49
+ "blocks": [
50
+ { "type": "hero", "heading": ["A small studio", "with big ideas"], "intro": "Six people." },
51
+ { "type": "text", "heading": "How we work", "body": "One team from start to launch." },
52
+ { "shared": "start-a-project" }
53
+ ]
54
+ }
55
+ ```
56
+
57
+ - `title` is required. It names the page in the editor and in the browser's title
58
+ (`<title> · <brand name>`, unless `seo.title` is set; the config's `titleSeparator` can
59
+ make the ` · ` another separator); it isn't necessarily drawn on the page. The home page
60
+ is the exception: its title is its own `seo.title`, else Site Settings' `seo.title`, else
61
+ the brand name (the document's `title` only when all of those are empty). With the
62
+ config's `titleFallback: 'site'`, every page falls back that way, not only the home page.
63
+ - Each block is `{ "type": <a key of the config's blocks>, ...its fields }`. A field left
64
+ out reads as empty.
65
+ - `{ "shared": "<name>" }` places the shared block `content/shared/<name>.json` there.
66
+ - If the collection has `fields` of its own (page fields), they're top-level keys after
67
+ `title` and before `blocks`.
68
+ - An optional `"seo": { "title", "description", "image" }` sets the page's search and
69
+ share text; leave it out to use Site Settings' defaults.
70
+
71
+ A fields document: `{ "$schema", "title", ...fields }`, keys in the config's order.
72
+
73
+ A shared block: `{ "$schema": "../../schemas/shared.schema.json", "title": "Start a project", "block": { "type": "cta", ... } }`.
74
+
75
+ ## Field values
76
+
77
+ Match each field's kind in the config (`text()`, `richText()`, …):
78
+
79
+ | Field | Stored as |
80
+ |---|---|
81
+ | `text()` | `"a string"`; with `multiline: true`, `\n` between lines |
82
+ | `lockup({ style })` | lines: `["A small studio", "with big ideas"]`, or with phone lines `{ "lines": [...], "phone": [...] }` |
83
+ | `select({ options })` | one option's `value` |
84
+ | `richText()` | a list, below |
85
+ | `link()` | `{ "label": "Contact us", "href": "/contact" }` |
86
+ | `link({ addressOnly: true })` | `{ "href": "/contact" }` |
87
+ | `buttons({ styles })` | `[{ "label", "href", "style": <a style's value> }]` |
88
+ | `toggle()` | `true` / `false` |
89
+ | `number()` | a number |
90
+ | `date()` | `"2026-10-21"` |
91
+ | `media()` | an image or a clip, below |
92
+ | `reference({ to })` | the referenced document's path in that collection, as a string: `"harbour-coffee"` |
93
+ | `list(field)` | an array of that field's values |
94
+ | `list({ a: …, b: … })` | an array of objects, keys in the config's order |
95
+ | `group({ … })` | one object, keys in the config's order |
96
+
97
+ **Links** (`href`, in links and buttons) are a `/path` on the site, a full `https://` URL,
98
+ `mailto:someone@example.com`, `tel:+353…`, or a fragment: `#anchor`, or `#` alone (a
99
+ placeholder). Anything else shows as plain text.
100
+
101
+ **Rich text** is a list of single-key objects:
102
+
103
+ ```json
104
+ "body": [
105
+ { "p": "We drew the letters from the old **ferry signs**, see [the print work](/services/print)." },
106
+ { "h2": "From the harbour wall" },
107
+ { "ul": ["First point", "Second point"] },
108
+ { "ol": ["Step one", "Step two"] }
109
+ ]
110
+ ```
111
+
112
+ - `p`, `ul` and `ol` always; `h2` and `h3` only if the field allows them
113
+ (`richText({ headings: ['h2'] })`); never `h1`.
114
+ - Inline: `**bold**`, `*italic*`, `[text](href)` (unless the field has `links: false`),
115
+ `\n` for a line break. Marks open and close inside one paragraph, heading or list item.
116
+ Write a literal `*`, `[`, `]` or `\` as `\*`, `\[`, `\]`, `\\`.
117
+
118
+ **Images** are `{ "key": "uploads/1fb083002e3a/harbour-wall.jpg", "alt": "The harbour wall at dusk", "w": 4000, "h": 2667 }`,
119
+ optionally `"focal": [0.4, 0.3]` (the point to keep in a crop, from the left and the top,
120
+ 0–1) or `"decorative": true` (then `"alt": ""`).
121
+
122
+ - The `key` must already be in the site's media library. **You can't upload from a
123
+ content change.** Reuse a key that's already in the content, or ask a person to upload
124
+ the image in the editor (or a developer to run `caracms media import`, which prints the
125
+ key, `w` and `h`).
126
+ - Give every image its `w` and `h`. Without them it still renders, but the page can't keep
127
+ its space while it loads, and `validate` warns. `caracms media import <the image's file>`
128
+ prints them (an image already in the bucket isn't uploaded again). A file's key (a PDF
129
+ in a media slot) needs no size.
130
+ - `alt` says what the image shows, for someone who can't see it.
131
+ - A clip (a Mux video) is `{ "video": "<playback ID>", "play": "loop" | "player" }`; only
132
+ the editor makes those.
133
+
134
+ ## Site Settings
135
+
136
+ `content/site-settings.json` holds:
137
+
138
+ - `editors`: the email addresses that can sign in to the editor. **To add a person,** add
139
+ their address to this list.
140
+ - `brand`: `name` (the site's name in the editor and in titles) and `accent` (`#rrggbb`).
141
+ The logo and icon are uploaded in the editor.
142
+ - `seo` (the default search title, description and share image), `googleTag`
143
+ (`G-…` or `GTM-…`), `formRecipients` (`{ "<form id>": ["someone@example.com"] }`),
144
+ `redirects` (`[{ "from": "/old", "to": "/new" }]`).
145
+ - The site's own fields, from `caracms.config.ts` → `settings.fields` (a menu, footer
146
+ links, contact details…).
147
+
148
+ ## Moving or deleting a page
149
+
150
+ Prefer the editor for these: it updates links to the page and adds a redirect. By hand:
151
+ rename or delete the file, fix every `href` and `reference` that pointed at it (search
152
+ `content/` for the old address and path), and for a page that was public add
153
+ `{ "from": "/old-address", "to": "/new-address" }` to Site Settings' `redirects`.
154
+
155
+ A page can't go at an address the site's own code serves (a route in `src/pages/`), or at
156
+ `/admin`, `/_caracms` or `/files`.
157
+
158
+ ## Choosing blocks for a page
159
+
160
+ **Read the page component before choosing** (the config's `page`, usually
161
+ `src/components/caracms/Page.astro`). How a page looks around its blocks can depend on
162
+ what it's made of: the header's colour or variant, `<main>`'s classes or an intro can
163
+ follow from the page's first block (a map keyed by block type), or from a page field (a
164
+ key after `title`). Pick the first block and the page fields for the look the page should
165
+ have, and compare with an existing page that looks that way.
166
+
167
+ **When no block fits the content,** a content change can't make one: blocks are code, and
168
+ code is the `caracms-blocks` skill's. Don't bend a block that doesn't fit (a heading
169
+ squeezed into a plain text field), and don't write a component as part of a content
170
+ change. Either:
171
+
172
+ - say in the pull request what's missing and what you used instead, or
173
+ - make the block first, as a separate change with the `caracms-blocks` skill, then the
174
+ content on top of it.
175
+
176
+ Either way, tell the person who asked: they may want the block, or a different page.
177
+
178
+ ## Getting it merged
179
+
180
+ 1. **Work on a new branch** off the site's main branch, and open a pull request into it.
181
+ Never commit to `main` directly.
182
+ 2. **Never touch the `drafts` branch.** It's the editor's: it holds editors' unpublished
183
+ changes, and publishing from the editor commits to `main`. Don't push to it, merge it
184
+ or base work on it.
185
+ 3. **Run `npx caracms validate` from the site's root before you open the pull request**
186
+ (with pnpm, `pnpm caracms validate`). It checks every content file against
187
+ `caracms.config.ts`:
188
+ - Each problem is a line `file › path.in.the.json: what's wrong`, under `Errors:` or
189
+ `Warnings:`. It exits 1 when there are errors. Fix every error.
190
+ - Warnings (a missing `$schema`, schema files out of date, a heading too long for its
191
+ box, an unknown select value, an image with no `w` or `h`, a file the editor's save
192
+ would reformat) don't break anything, but fix the ones you caused.
193
+ - `npx caracms validate --fix` writes the schemas, adds any missing `$schema` keys and
194
+ lays out each file as the editor saves it, changing only whitespace (that's all it
195
+ fixes). `--json` prints `{ "errors": [...], "warnings": [...] }`,
196
+ each `{ "file", "path", "message" }`.
197
+ 4. Keep the pull request to content (`content/`, and `schemas/` if `--fix` changed it).
198
+ Changing blocks or the config is the `caracms-blocks` skill's, in a change of its own
199
+ (see Choosing blocks for a page).
200
+
201
+ The site builds from these files, so a merged content change is live on the next deploy.