caracms 0.1.0 → 0.3.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 (93) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +65 -20
  6. package/bin/templates/catch-all.astro.txt +3 -2
  7. package/package.json +1 -1
  8. package/skills/caracms-adopt/SKILL.md +17 -3
  9. package/skills/caracms-adopt/look-same.mjs +24 -11
  10. package/skills/caracms-blocks/SKILL.md +69 -4
  11. package/skills/caracms-content/SKILL.md +2 -1
  12. package/skills/caracms-tina/SKILL.md +431 -0
  13. package/skills/caracms-tina/field-kinds.md +88 -0
  14. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  15. package/src/block-runs.ts +84 -0
  16. package/src/cli/validate.ts +10 -5
  17. package/src/components/BlockRunTree.astro +45 -0
  18. package/src/components/Blocks.astro +20 -1
  19. package/src/components/Form.astro +76 -10
  20. package/src/components/Head.astro +4 -2
  21. package/src/components/Media.astro +27 -1
  22. package/src/components/RichText.astro +12 -4
  23. package/src/components/Text.astro +32 -9
  24. package/src/components/form-client.ts +7 -4
  25. package/src/components/sitemap.xml.ts +2 -1
  26. package/src/config.ts +32 -1
  27. package/src/content-schema.ts +7 -4
  28. package/src/content.ts +28 -0
  29. package/src/document.ts +15 -2
  30. package/src/editor/client/block-list/summary.ts +7 -1
  31. package/src/editor/client/editor.ts +24 -6
  32. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  33. package/src/editor/client/fields/LinkField.tsx +17 -12
  34. package/src/editor/client/fields/ReferenceField.tsx +6 -0
  35. package/src/editor/client/fields/RichTextField.tsx +38 -32
  36. package/src/editor/client/fields/host.tsx +5 -0
  37. package/src/editor/client/fields/parts.tsx +2 -1
  38. package/src/editor/client/fields/rich-text-doc.ts +28 -2
  39. package/src/editor/client/fields/rich-text-schema.ts +41 -2
  40. package/src/editor/client/live/publish.tsx +20 -10
  41. package/src/editor/client/nav/DocRow.tsx +6 -0
  42. package/src/editor/client/overlays/rich-text-bar.ts +6 -6
  43. package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
  44. package/src/editor/client/pane/parts.tsx +34 -2
  45. package/src/editor/client/preview/driver.ts +5 -5
  46. package/src/editor/client/preview/field-text.ts +40 -2
  47. package/src/editor/client/preview/page-edit.ts +2 -2
  48. package/src/editor/client/preview/page-editor-registry.ts +4 -3
  49. package/src/editor/client/preview/page-editor.ts +3 -3
  50. package/src/editor/client/state/document-store.ts +20 -2
  51. package/src/editor/client/state/keeper.ts +51 -6
  52. package/src/editor/client/state/live.ts +45 -6
  53. package/src/editor/middleware.ts +3 -2
  54. package/src/editor/routes/try-again.ts +2 -1
  55. package/src/editor/server/admin-props.ts +2 -1
  56. package/src/editor/server/mode.ts +13 -3
  57. package/src/editor/styles/editor.css +2 -0
  58. package/src/editor/styles/levels/live.css +1 -1
  59. package/src/editor/types.ts +27 -6
  60. package/src/fields.ts +63 -15
  61. package/src/files-route.ts +9 -4
  62. package/src/forms/endpoint.ts +11 -16
  63. package/src/forms/rules.ts +9 -1
  64. package/src/head.ts +17 -4
  65. package/src/index.ts +53 -7
  66. package/src/links.ts +35 -0
  67. package/src/live/README.md +5 -2
  68. package/src/live/auth/auth.ts +4 -1
  69. package/src/live/auth/guard.ts +3 -2
  70. package/src/live/auth/index.ts +6 -7
  71. package/src/live/auth/login.ts +17 -8
  72. package/src/live/auth/middleware.ts +7 -3
  73. package/src/live/auth/request.ts +92 -11
  74. package/src/live/auth/views.ts +9 -4
  75. package/src/live/autosaves.ts +59 -16
  76. package/src/live/backend.ts +54 -24
  77. package/src/live/branches.ts +19 -0
  78. package/src/live/drafts-store.ts +4 -3
  79. package/src/live/editor.ts +92 -67
  80. package/src/live/git.ts +112 -34
  81. package/src/live/github.ts +128 -13
  82. package/src/live/locks.ts +92 -26
  83. package/src/live/mode.ts +177 -18
  84. package/src/live/notices.ts +100 -0
  85. package/src/live/redis.ts +161 -39
  86. package/src/live/runtime.ts +15 -4
  87. package/src/live/status-cache.ts +219 -0
  88. package/src/live/status.ts +14 -2
  89. package/src/live/timings.ts +24 -0
  90. package/src/media.ts +40 -7
  91. package/src/read.ts +2 -1
  92. package/src/rich-text.ts +30 -5
  93. package/src/serialize.ts +10 -1
@@ -0,0 +1,431 @@
1
+ ---
2
+ name: caracms-tina
3
+ description: Port a site built on self-hosted Tina (a Caracode Tina template site, with `tina/`, `server.mjs` and pages as JSON read through `src/lib/sections.ts`) onto caracms — strip Tina, run the reference conversion script (`tina-to-caracms.mjs`, re-runnable on newer content), rewrite each block onto `Block`, `Text`, `RichText` and `Media` so text is typed on the page, carry page-wide rules and cards into the page component, deploy to a branch environment that can't publish to the live site, and prove visitors' pages look the same. Use when porting a Tina site, re-running its conversion, or adapting the script.
4
+ ---
5
+
6
+ # Porting a Tina site onto caracms
7
+
8
+ **The bar: visitors' pages look the same.** The port changes what editors see, not what
9
+ visitors see. Any redesign is separate work, after the switch.
10
+
11
+ **The port is real, and the live site keeps moving.** The client keeps editing in Tina
12
+ while the port's branch is open. So content is never converted by hand: the site's own
13
+ copy of the conversion script writes `content/`, and at the switch Tina is frozen, the
14
+ script is re-run on the latest `main`, and the branch is merged. Anything you'd fix in a
15
+ converted file, fix in the script (or the site's adapt table) and re-run.
16
+
17
+ Read with this skill:
18
+
19
+ - `caracms-blocks`: blocks, fields, collections, the page component, forms.
20
+ - `caracms-adopt`: `init` in detail, the look-same script (section 9), motion under the
21
+ preview (section 8). This skill is the Tina-specific order; adopt's page-by-page
22
+ method doesn't apply, since the script converts every page at once.
23
+ - `caracms-content`: the content files the script writes.
24
+ - Beside this file: **`tina-to-caracms.mjs`**, the reference script (its header is its
25
+ manual), and **`field-kinds.md`**, the mapping table: how each Tina field kind becomes a
26
+ caracms field and value.
27
+
28
+ Commands run from the site's root: `npx caracms …` (or `pnpm caracms …`).
29
+
30
+ **Swapping in a new packed tarball** at the same `file:` path (`vendor/caracms.tgz`):
31
+ `pnpm install`, even with `--force`, keeps the old copy, since the lockfile holds the old
32
+ file's integrity. Remove and add it again:
33
+ `pnpm remove caracms && pnpm add caracms@file:vendor/caracms.tgz`.
34
+
35
+ ## 0. Before anything moves: the block table
36
+
37
+ A template site has its own block templates (`_template` names, camelCase). Propose the
38
+ **old-to-new block table** to Rich and get it approved before content moves: each Tina
39
+ template, the caracms block key it becomes, and whether it maps one to one, splits or
40
+ is flattened. Keys are kebab case (`textMedia` → `text-media`, the script's default);
41
+ rename where the editor's label reads better (`features` → `steps`, `fullMedia` →
42
+ `wide-media`). The approved renames go into the script's adapt table (`blockRenames`).
43
+
44
+ Also list, for the same approval: the fields that will be **edited in the sidebar only**
45
+ (display text fitted to its box, split-letter headings, a word drawn as artwork), and
46
+ anything the template does that caracms has no place for yet.
47
+
48
+ ## 1. The order
49
+
50
+ Work on a branch (`caracms-port`, say) in a worktree of the site. Commit after each step.
51
+
52
+ 1. **Bump Astro on its own commit** to the version caracms needs (its peer dependency:
53
+ `npm view caracms peerDependencies`, or the tarball's `package.json` while the package
54
+ is unpublished or the port runs on a packed tarball). Build and check the site still looks the same
55
+ before anything else changes. If the site pins its own `vite`, bump it with Astro:
56
+ left behind, it leaves a second Vite in the tree and `astro check` fails in the Astro
57
+ config. **Bump `.nvmrc` and `engines.node` in `package.json` with Astro** (Astro 7.4
58
+ needs Node 22.23.4 or later, and so does caracms): a host that reads them builds on
59
+ the older Node otherwise (Railway fails with exit 1 and no error text in the build
60
+ log).
61
+ 2. **Strip Tina** (section 3): `tina/`, `server.mjs`, `server/`, Tina's routes and admin,
62
+ **including the untracked `tina/__generated__/` and `public/admin/`** (they're in
63
+ `.gitignore`; delete them from disk too, or `init` and the build trip on them).
64
+ 3. **Delete `content/`** (git keeps it). `init` refuses a `content/` that isn't caracms's.
65
+ 4. **Switch the adapter to standalone:** `@astrojs/node` with `mode: 'standalone'`.
66
+ `caracms start` serves `dist/server/entry.mjs` and refuses any other mode.
67
+ 5. **`npx caracms init`**, read the dry run, then **`npx caracms init --write`** and install
68
+ again (caracms-adopt → section 1). It links the four skills, writes the config, the
69
+ CLAUDE.md block and the health route. **A template site already has a
70
+ `.railway/railway.ts` that describes production: leave it alone and never apply it**
71
+ (applying it can switch production's CDN off). `init` treats a site that has one as
72
+ a Railway site and leaves the file untouched, which is the expected outcome. Set the
73
+ branch environment up with the Railway CLI or MCP, scoped to that environment
74
+ (section 9).
75
+ 6. **Copy the script and run it** (section 2) against a **built** checkout of `main`.
76
+ First run with `--scaffold-config` for a starting `caracms.config.ts`. It names a
77
+ component per block before any is written, so nothing builds until they exist: point
78
+ every block's `component` at one stub first, then swap each in as it's rewritten.
79
+ 7. **Rewrite the blocks** onto caracms's components (section 4), the page component
80
+ (section 5), the routes (section 6), forms (section 8).
81
+ 8. **`npx caracms validate`**: no errors and no warnings, the missing-`w`/`h` warning
82
+ included (section 7 fills sizes in).
83
+ 9. **Look-same** every public page against `main` at the commit the script last ran on
84
+ (section 11), then the branch environment (section 9).
85
+
86
+ ## 2. The conversion script
87
+
88
+ Copy `node_modules/caracms/skills/caracms-tina/tina-to-caracms.mjs` into the site's own
89
+ `scripts/`. The site's copy is the one that runs; it's committed with the site.
90
+
91
+ **Adapt only the table under `ADAPT HERE`** (the script's header says what each entry is):
92
+ the approved `blockRenames`, `fieldDefaults` (values Tina's files leave out but the
93
+ template's mapper code read as set: a toggle that defaulted on, a per-block background
94
+ default that isn't the first option), `addressFields`, `headingFields`, `docTitleAlt`,
95
+ the file-link pattern, and Tina's home file. **`addressFields` matters:** Tina's schema has
96
+ no marker for a link field (a custom component serialises to `ui: {}`), so only strings
97
+ named `href` are known to be addresses; list every other one. Everything below the table is generic and
98
+ driven by Tina's generated schema; never add per-block code. If a block needs something
99
+ the table can't say, that's a gap in the script: change the generic part, and say so in
100
+ your report so it reaches the package's copy.
101
+
102
+ Run it from the site's root:
103
+
104
+ ```bash
105
+ node scripts/tina-to-caracms.mjs --from ../share-web-main --scaffold-config
106
+ ```
107
+
108
+ - `--from` is a checkout of the Tina site's `main` that has been **built** (`pnpm build`,
109
+ or the template's `build:local`), so `tina/__generated__/_schema.json` exists. Only that
110
+ schema is needed: the site's own build may fail there for lack of env, and that's fine.
111
+ If `build:local` stops on a busy port 9000 (Tina's datalayer), pass
112
+ `--datalayer-port <another>` to the Tina site's own `build:local` / `tinacms build`, not
113
+ to this script (it rejects unknown arguments).
114
+ - Run it after `caracms init --write` and an install: it borrows the installed package's
115
+ image sniffer and S3 signer.
116
+ - `--scaffold-config` writes a starting `caracms.config.ts` from Tina's schema, with
117
+ Tina's labels and help. **Once.** After that the config and the block components are
118
+ the site's own, hand-maintained, and a normal run never touches them (section 4 says
119
+ which options only you can add).
120
+ - `--recipients <address>` replaces every form's recipients in Site Settings with one
121
+ address. **Always pass it for a branch environment**: the converted Site Settings name
122
+ the client, and a test submission would email them. The run at the switch leaves it out.
123
+
124
+ **What it owns: `content/`, nothing else.** Each run deletes `content/` and writes it again,
125
+ laid out the way the editor saves it, so the same Tina content gives byte-identical files
126
+ and `validate` finds nothing to reformat. **A re-run deletes `content/` whole**, so anything
127
+ published from the branch environment's editor is replaced by it. That is by design: Tina
128
+ stays the source until the switch. Beside it, it only adds to
129
+ `scripts/tina-media-sizes.json` (images' `w`/`h`, commit it) and copies file-link objects
130
+ in the bucket to caracms file keys (skipped when the key exists; it never deletes).
131
+ Without `S3_*` set, it still writes `content/` and lists the copies still to make (a
132
+ rulebook PDF, say): run it again with the environment's bucket credentials to make them.
133
+
134
+ To pick up newer Tina content: pull `main` in the `--from` checkout, build it, re-run,
135
+ commit `content/` and the size cache, and look-same again against that commit.
136
+
137
+ What the script converts (the full table is `field-kinds.md`): `_template` → `type`,
138
+ the section list → `blocks`, the home file → `index.json`, Tina's Markdown rich text →
139
+ caracms's (Tina's escapes such as `A\&M` and `mailto\:` undone), an image and its `…Alt`
140
+ sibling → one media value, a Mux clip → a video value, `linkLabel` + `href` → one
141
+ `link()`, references → document paths, Site Settings (`formNotifications` →
142
+ `formRecipients`, `adminBranding` → `brand`, `adminAccess` → `editors`, `defaultSeo` →
143
+ `seo`), file links → caracms file addresses, and Tina's `hidden` → `"hidden": true`
144
+ (section 6).
145
+
146
+ ## 3. Tina's pieces and what replaces each
147
+
148
+ | The template's | Goes | Replaced by |
149
+ |---|---|---|
150
+ | `tina/`, `tina/__generated__/`, Tina's dependencies | yes | `caracms.config.ts` |
151
+ | `public/admin/`, `public/admin-previews/` | yes | `/admin` (caracms's editor) |
152
+ | `server.mjs`, `server/` (Express: forms, downloads, the admin, previews) | yes | `caracms start`; forms → `/_caracms/form` (section 8); downloads → caracms file addresses `/files/<12 hex>/<name>` |
153
+ | `src/pages/[...slug].astro` and other collection routes reading Tina | yes | caracms routes (section 6) |
154
+ | `src/lib/sections.ts` (the mappers: Tina document → component props) | yes, except page-wide logic | blocks draw from their own data; page-wide rules → the page component (section 5) |
155
+ | `Sections.astro` / `PageView` | yes | the page component and `<Blocks>` |
156
+ | `data-tina-field` on every element | yes | caracms's markers, added by `Block`, `Text`, `RichText`, `Media` in a preview only |
157
+ | `islandPreview.ts`, Tina's preview islands | yes | caracms's preview; site scripts listen for `caracms:rendered` (section 4) |
158
+ | `@astrojs/sitemap` | yes | caracms's `/sitemap.xml` (needs `SITE_URL`) |
159
+ | `.template-sync`, engine-sync scripts, the deploy-gate workflow, `@caracode/pins` | yes | nothing (`.carapin/` can stay as history) |
160
+ | The header, footer, motion, fonts, analytics tags, design-system routes, the site's own `src/lib/` logic | **stays** | |
161
+
162
+ **Tests:** delete tests of Tina's machinery, of the mappers, of deleted files, and of
163
+ `data-tina-field`. A test that guards site behaviour that still exists is rewritten
164
+ against caracms's content format; the template's helper that reads committed content is
165
+ usually the one to rewrite first. **Docs:** the engine and Tina docs go (Tina behaviour,
166
+ publishing, admin login, the media pipeline, downloads). The site's own half of
167
+ `docs/decisions.md` stays, rewritten where it describes Tina. CLAUDE.md loses the
168
+ engine/site split; `init` added its caracms block.
169
+
170
+ ## 4. Rewriting a block onto caracms's components
171
+
172
+ **The default: every block draws through caracms's components**, keeping the template's
173
+ markup and classes exactly. That's what makes its text typed on the page in `/admin`.
174
+ (Keeping the template's components behind a reverse adapter, feeding them props from
175
+ caracms's data, is faster to look-same but leaves every field sidebar-only. Use it only
176
+ as a stepping stone, a block at a time.)
177
+
178
+ ```astro
179
+ ---
180
+ import Block from 'caracms/components/Block.astro';
181
+ import Media from 'caracms/components/Media.astro';
182
+ import RichText from 'caracms/components/RichText.astro';
183
+ import Text from 'caracms/components/Text.astro';
184
+ import type { BlockProps } from 'caracms/document';
185
+ import type { TextMediaData } from './text-media.ts';
186
+
187
+ type Props = BlockProps<TextMediaData>;
188
+ const { block, cms } = Astro.props;
189
+ ---
190
+
191
+ <Block cms={cms} class={`section bg-${block.background}`} id={block.anchor || undefined}>
192
+ <Text cms={cms} field="heading" value={block.heading} class="heading text-balance" lineClass="block text-balance" />
193
+ <RichText cms={cms} field="body" value={block.body} class="prose" />
194
+ <Media cms={cms} field="media" value={block.media} sizes="(min-width: 1024px) 50vw, 100vw" class="media" />
195
+ </Block>
196
+ ```
197
+
198
+ - **`Block`** is the block's root (`as="section"` by default; `as` and every attribute
199
+ pass through). Drop the template's `data-tina-field`.
200
+ - **`Text`** for a plain text field: `as` sets the tag; a field declared
201
+ `text({ heading: true })` is the page's `h1` when it's the first heading, else `h2`.
202
+ - **Lines** (`text({ lines: true })` in the config, `lineClass` on `Text`): for a heading
203
+ the template drew one element per typed line (its `typography.ts`/`Heading.astro`).
204
+ Two lines or more draw a `<span class={lineClass}>` each, blank lines draw nothing,
205
+ and a single line is drawn bare, as the template's `ui/Heading.astro` does. Typed on
206
+ the page, Shift+Enter still stores `\n`.
207
+ - **`RichText`** for rich text: semantic HTML with no classes, inside one wrapper (`as`,
208
+ default `div`). Style it from the wrapper's class (`.prose p`, `.prose strong`).
209
+ - **A rich-text heading** (`richText({ heading: true })`): for a heading the template
210
+ drew from rich text with bold as a highlight colour and each paragraph a line
211
+ (`ui/RichLine.astro`). It draws inline runs joined by `<br>` inside its own heading
212
+ tag (`h1` when it's the page's first heading), never `<p>`. It takes bold and line
213
+ breaks only, typed on the page. The template's highlight class becomes site CSS on
214
+ `strong` inside it: `.statement strong { color: var(--highlight); }`.
215
+ - **`Media`** for an image or a Mux clip: one `<img>` with a srcset (or the clip's
216
+ video). Fluid: `sizes` (and `ratio` to crop); fixed: `width`/`height`. For flat
217
+ artwork the template served as PNG (WebP smears it), declare the field
218
+ `media({ format: 'png' })`: every width is then `@png`.
219
+ - **YouTube**: a `link({ addressOnly: true })`; the block parses the id from the address.
220
+ - **Sidebar-only fields** (fitted display text, split letters, a word drawn as artwork):
221
+ draw the value yourself (`{block.word}`) instead of through `Text`, so it isn't typed
222
+ on the page. Text the template splits into letters for motion can be drawn unsplit in
223
+ the preview (`cms.preview`), where motion is off, and then typed on the page.
224
+ - **Help:** Tina's long block descriptions go into the block's `help` (one or two
225
+ sentences, shown at the top of the block in the sidebar). Field `help` stays short.
226
+ - **Site scripts and the preview:** the preview re-renders a block in place after each
227
+ edit (a morph, no reload). A script that measures or fits text (`fitText.ts`) re-runs
228
+ on it: `document.addEventListener('caracms:rendered', run)` (fired on the frame's
229
+ `document` after each morph, never on public pages). Motion follows caracms-adopt →
230
+ section 8.
231
+
232
+ The options only you can add (`--scaffold-config` doesn't know them): `heading: true` on
233
+ the block's heading field, `lines: true`, `richText({ heading: true })`,
234
+ `media({ format: 'png' })`, `media({ play: 'loop' })` for a hero's loop, `allowNoLink: true`
235
+ on buttons that may be a label alone ("Coming soon": `validate` warns on one until it's
236
+ set), select defaults from mapper code, and trimmed `help`.
237
+
238
+ ## 5. The page component: page-wide rules and cards
239
+
240
+ The template's page-wide logic (`pageRules.ts`, `pageParts()`) moves into the page
241
+ component (`page:` in the config). It gets `document`, `collection`, `path` (the page's
242
+ path in its collection, `null` for none), `preview`, `data`, `settings` (Site
243
+ Settings), `listPage` and `read`.
244
+
245
+ - **A block sees only its own data.** Page-wide rules the Tina mappers applied across
246
+ blocks (dropping an anchor already used higher up the page, say) belong here, through
247
+ `<Blocks>`'s context or its `wrap` rule. Check whether today's content needs each one
248
+ before carrying it over.
249
+ - **Titles.** Tina template sites show Site Settings' title on every page without its own
250
+ `seo.title`, not `<page> · <brand>`: set `titleFallback: 'site'` in the config
251
+ (caracms-adopt → Keep the old title format). Check the `<title>` lines in look-same's
252
+ HTML diff.
253
+ - **Time zone.** A Tina template site keeps its zone in the `SITE_TIMEZONE` variable;
254
+ caracms reads `timeZone` in the config (default UTC) for the editor's and the inbox's
255
+ times. Copy production's value into the config.
256
+ - **The h1 and eager loading** are caracms's own: the first block with a heading holds
257
+ the `h1`, and the first two blocks' images load eagerly (`cms.first`, `cms.eager`). Drop
258
+ the template's versions.
259
+ - **Cards** (a run of neighbouring blocks drawn inside a band, each card holding several
260
+ blocks, the anchor `id` on the card): give `<Blocks>` a `wrap` rule. It gets the blocks
261
+ that render (`{ index, type, data }`, `data` as the block gets it) and returns runs:
262
+ `{ from, to, component, props, runs }`, document indices inclusive, with `runs` nested
263
+ inside (the cards inside a band). Each block inside keeps its own index, context and
264
+ preview markers, so it's selected, typed into and morphed as anywhere else.
265
+
266
+ ```astro
267
+ ---
268
+ import Blocks from 'caracms/components/Blocks.astro';
269
+ import type { BlockRun, PageProps, WrapBlocks } from 'caracms/document';
270
+ import CardBand from './CardBand.astro';
271
+ import Card from '../components/ui/Card.astro';
272
+
273
+ type Props = PageProps;
274
+ const { document, collection, preview = false, listPage } = Astro.props;
275
+
276
+ /** The template's pageParts(), on the blocks' `card` field: "start" opens a card, "continue" joins the one above. */
277
+ const cards: WrapBlocks = (blocks) => {
278
+ const bands: { from: number; to: number; cards: BlockRun[] }[] = [];
279
+ for (const b of blocks) {
280
+ const spot = b.data.card as string | undefined;
281
+ if (!spot || spot === 'none') continue;
282
+ let band = bands.at(-1);
283
+ // A band is a run of neighbouring carded blocks: a gap (an uncarded block) starts a new one.
284
+ if (!band || band.to !== b.index - 1) bands.push((band = { from: b.index, to: b.index, cards: [] }));
285
+ const card = band.cards.at(-1);
286
+ if (spot === 'start' || !card) band.cards.push({ from: b.index, to: b.index, component: Card, props: { id: (b.data.anchor as string) || undefined } });
287
+ else band.cards[band.cards.length - 1] = { ...card, to: b.index };
288
+ band.to = b.index;
289
+ }
290
+ return bands.map((band) => ({ from: band.from, to: band.to, component: CardBand, runs: band.cards }));
291
+ };
292
+ ---
293
+
294
+ <Blocks document={document} collection={collection} preview={preview} listPage={listPage} wrap={cards} />
295
+ ```
296
+
297
+ A wrapper component renders `<slot />` where the blocks go. Rules that break the runs'
298
+ order (overlapping, out of order, a card outside its band) stop the render with a
299
+ sentence: that's a bug in the rule. The block's anchor `id` goes on the card, not the
300
+ block, when the template put it there: section links jump to those ids.
301
+
302
+ ## 6. Routes, hidden pages and the 404
303
+
304
+ `init` writes the pages' catch-all (`src/pages/[...path].astro`) once Tina's
305
+ `[...slug].astro` is gone (it refuses while another route catches the top of the site).
306
+ Write one route per other collection with an address (`src/pages/games/[slug].astro`) the
307
+ same way: `listDocuments` + `readDocument` + `RenderDocument`. Every `getStaticPaths`
308
+ leaves out **`e.warning`** (a file that can't be opened) and **`e.hidden`** (a hidden page,
309
+ or one under it):
310
+
311
+ ```ts
312
+ const entries = (await listDocuments(site, 'pages')).filter((e) => !e.warning && !e.hidden);
313
+ ```
314
+
315
+ **Hidden pages:** Tina's `hidden` becomes `"hidden": true` at the top of the document,
316
+ after its title. A hidden page isn't served (visitors get the site's own `404.astro`),
317
+ isn't in the sitemap, isn't offered by the link picker, and isn't in a block's
318
+ `cms.read.documents(…)`; pages nested under it are hidden with it. It stays editable,
319
+ previewable and publishable in `/admin`, marked "Hidden" in the pages list, with a
320
+ "Hidden from the site" switch in its page settings. The home page can't be hidden (the
321
+ script warns and leaves it shown). A link to a hidden page is a warning, not an error,
322
+ in the editor and in `validate`, naming the page.
323
+
324
+ ## 7. Images
325
+
326
+ - **Keep the bucket keys.** The template's images are imgproxy URLs over the media bucket
327
+ (`…/unsafe/plain/s3://<bucket>/uploads/…`); the script keeps each `uploads/…` key, so no
328
+ image is copied and the bucket only grows.
329
+ - **`w`/`h` are found once** and cached in `scripts/tina-media-sizes.json` (commit it). The
330
+ first run needs the images reachable (their Tina URLs) or the bucket (`S3_*`); after
331
+ that the cache answers. `validate` warns on any image still missing its size.
332
+ - **imgproxy:** caracms signs every URL (`CARACMS_MEDIA_URL`, `IMGPROXY_KEY`,
333
+ `IMGPROXY_SALT`, `S3_BUCKET`). The template's production imgproxy is unsigned and its
334
+ URLs are cached by the CDN, so **never give it a key while Tina runs**: stand up a new,
335
+ signed imgproxy beside it, reading the same bucket. Without the media variables the
336
+ preview draws no images, and a build stops with a sentence naming them.
337
+
338
+ ## 8. Forms
339
+
340
+ Both of a template's usual forms fit caracms's own: define them in the site's forms
341
+ (caracms-blocks → forms), and their submissions go to the inbox in `/admin`, with a
342
+ notification to Site Settings' `formRecipients`. A form drawn with `<Form>` is styled from
343
+ the site's CSS to the template's markup; its hooks (caracms-blocks → Forms: `submitClass`,
344
+ `thanks`, `errors`, `actions`, `labelPlaceholders`, attributes on the form, a textarea's
345
+ `rows`) carry the template's button classes, its wording and its layout, so no wrapper or
346
+ copied CSS is needed for them.
347
+
348
+ A form that keeps its own markup (a footer's newsletter signup) posts to caracms's
349
+ endpoint by its **wire contract**:
350
+
351
+ - `POST /_caracms/form`, urlencoded (or `multipart/form-data` when the form has a files
352
+ field; JSON is accepted too).
353
+ - `form`: the form's id. `return`: the page's path, where a no-script post comes back to.
354
+ - Each field by its name. `fax`: the honeypot, an empty text input hidden from people
355
+ (`tabindex="-1"`, off-screen); filled in, the post is dropped and answered as sent.
356
+ - `cf-turnstile-response`: Turnstile's token, added by Cloudflare's widget when the site
357
+ has `TURNSTILE_SITE_KEY` (at build) and `TURNSTILE_SECRET_KEY` (at run time). Without
358
+ the secret the endpoint accepts and logs it.
359
+ - Answers: with `Accept: application/json`, `200 {"ok":true}` or the status with
360
+ `{"error":"…"}` (400 a field, 403 the bot check, 429 too many, 502 storing failed);
361
+ without, a `303` to `<return>?sent=1`, or the error as plain text.
362
+
363
+ Not `/api/forms` with `source`/`redirectTo` (the template's): nothing stays a site
364
+ endpoint. The template's existing submissions stay in its own bucket and need no
365
+ conversion: caracms's inbox reads the template's records as they are (`<id>.json` with
366
+ `v: 1`, files at `<id>/<n>-<name>`).
367
+
368
+ ## 9. The branch environment
369
+
370
+ The port is deployed from its branch to its own environment, **never production**. The
371
+ template sites run on Railway, so this section is written for it. A site on another host
372
+ needs the same pieces there (the caracms README's Hosting section has each host):
373
+
374
+ - A new environment in the site's Railway project (`caracms`), with its own `web`, Redis, a
375
+ signed imgproxy and its own submissions bucket. It reads the live media bucket (caracms
376
+ only ever adds to a bucket). Its variables copy production's where they carry over
377
+ (`RESEND_API_KEY`, `FORMS_FROM`, the site's `MUX_TOKEN_*`, `CARACMS_ALLOW` from the
378
+ template's admin allow list), and it deploys from the port branch.
379
+ - **`CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH`** name caracms's two branches
380
+ (default `main` and `drafts`). Set them to the port branch and its own drafts branch
381
+ (`caracms-port`, `caracms-drafts`) **before anyone signs in**: a Publish there then
382
+ commits to the port branch and redeploys the environment, and the live site's `main`
383
+ never moves. With the defaults, the first Publish would land caracms's content on the
384
+ live site's `main`.
385
+ - A GitHub credential on the environment, as for any caracms site: a fine-grained token
386
+ on the site's repo, or the App (the caracms README → Hosting → GitHub).
387
+ - Convert with `--recipients <agency address>`, so test submissions never reach the client.
388
+ - **Never `railway config apply` against production**: production's variables are set
389
+ with `railway variables --set` (a template's Railway file can switch its CDN off).
390
+
391
+ **The switch** (its own task, later): Tina is frozen, the script is re-run on the latest
392
+ `main` without `--recipients`, the branch is merged, production's variables move to
393
+ caracms's (the branch variables unset), and Tina's imgproxy and variables retire.
394
+
395
+ ## 10. Before calling it done
396
+
397
+ - `npx caracms validate`: no errors, no warnings.
398
+ - `astro check` and `astro build` pass; the site's remaining tests pass.
399
+ - In `/admin`: every heading, intro, body, caption and button label the template marked
400
+ with `data-tina-field` can be typed on the page, except the sidebar-only fields you
401
+ listed; rich headings keep bold as the highlight after an edit; blocks inside cards
402
+ select and morph; a hidden page 404s publicly and edits in `/admin`.
403
+
404
+ ## 11. Look-same
405
+
406
+ Run caracms-adopt's look-same script (section 9) on every public page, the 404 included,
407
+ at 1440 and 390, against `main` at the commit the script last ran on (never production
408
+ as it is now: the content keeps moving). Build that baseline with **production's** media
409
+ base URL: a local build takes it from `.dev.env`, which may point at the template's
410
+ imgproxy, and then images render unresized and nothing compares. Pass the template's
411
+ attributes and random ids:
412
+
413
+ ```bash
414
+ node .claude/skills/caracms-adopt/look-same.mjs --playwright /tmp/look-same --old <main's build> --new <the port's build> --pages <list> --out <dir> --ignore-attr data-tina-field,data-tina-island --mask-ids auto
415
+ ```
416
+
417
+ If an element varies from run to run on the old site itself (a wordmark fitted to its box
418
+ while the display font loads), add `--css '<rules>'` to hide it or pin its size on both
419
+ sides (caracms-adopt → section 9).
420
+
421
+ **A 1px crop difference on a responsive image** is a known cause of a small pixel share.
422
+ caracms rounds each srcset width's crop height from the ratio, while an Astro `getImage()`
423
+ site scaled every width from the top width's rounded height (1280 × 875 where caracms
424
+ draws 1280 × 876 at 962:658). Match it in the site, not in caracms: the Share port reads
425
+ the image's ratio in `ladderRatio()` (`src/lib/media.ts`) and keeps the design ratio in
426
+ the frame's CSS.
427
+
428
+ Passing means a pixel share of 0.00% and no height change at both widths, and the same
429
+ status on both sides (a hidden page answers 404 on both). Review the HTML diff: every
430
+ difference should be one the rewrite explains (caracms's wrappers, Tina's attributes
431
+ gone).
@@ -0,0 +1,88 @@
1
+ # Tina field kinds → caracms
2
+
3
+ How `tina-to-caracms.mjs` maps each kind of Tina field. The script reads Tina's own generated schema (`tina/__generated__/_schema.json`) and applies this table to every field it finds, so nothing is written per block. The same table drives the content it writes and the starting config `--scaffold-config` writes.
4
+
5
+ "Schema path" below is how the script's adapt table names a field: `blocks.<template>.<field>` for a block's, `<collection>.<field>` for a collection's, `siteSettings.<field>` for Site Settings', with dots for nesting (`blocks.columns.items.media`).
6
+
7
+ ## Fields
8
+
9
+ | Tina field | caracms field | Stored value |
10
+ |---|---|---|
11
+ | `string` | `text()` | copied as it is |
12
+ | `string`, `ui.component: 'textarea'` | `text({ multiline: true })` | copied (`\n` between lines) |
13
+ | `string` whose name is in the adapt table's `headingFields` (in a block) | `text({ heading: true, … })` | copied |
14
+ | `string` with `options` | `select({ options })` (Tina's labels kept) | copied. A blank or unknown value is left out, so caracms's default applies (the first option, as Tina's "blank reads as the first option"), or the adapt table's `fieldDefaults` value |
15
+ | `string`, `list`, with `options` | `list(select({ options }))` | copied |
16
+ | `string`, `list` | `list(text())` | copied |
17
+ | `string` named `href`, or listed in `addressFields` | `link({ addressOnly: true })` | `{ "href": "…" }` |
18
+ | `number` | `number()` | copied |
19
+ | `boolean` | `toggle()` | copied; `fieldDefaults` fills a missing one (a default that lived in the mapper's code) |
20
+ | `datetime` | `date()` | copied |
21
+ | `image` with a sibling `<name>Alt` string | `media({ only: 'image' })` | `{ key, alt, w, h }`: the pair becomes one value, the `…Alt` field goes |
22
+ | `image` with no alt sibling | `media({ only: 'image' })` | `{ key, alt: "", decorative: true, w, h }`, or the document's title as alt when listed in `docTitleAlt` |
23
+ | `object` with `kind` and an image `src` (the template's `media()`) | `media()`, or `media({ only: 'image' })` when it has no `playbackId` | an image: `{ key, alt, focal, w, h }` (Tina's whole-percent `focusX`/`focusY` become `focal` 0–1). A Mux clip: `{ video, play, poster, name, duration }` (`playAs` → `play`, `posterTime` → `poster`) |
24
+ | `linkLabel` + `href` side by side | one `link()` named `link`, where `linkLabel` stood | `{ label, href }` |
25
+ | `object` list of `{ label, href, style }` | `buttons({ styles })` | `[{ label, href, style }]` |
26
+ | `object` list of `{ label, href }` | `list(link())` | `[{ label, href }]` |
27
+ | `object` list of one field (after the pairs merge) that is text, a reference or an image | a list of that field | flattened: `[{ line: "a" }]` → `["a"]`, `[{ game: "content/games/x.json" }]` → `["x"]`, `[{ image, imageAlt }]` → `[{ key, alt, w, h }]` |
28
+ | `object` list (anything else) | `list({ … })`, `itemLabel` the first text field | a list of objects, keys in schema order |
29
+ | `object` | `group({ … })` | an object, keys in schema order |
30
+ | `reference` | `reference({ to })` | the document's path in its collection: `content/games/x.json` → `x` (Tina's home file → `index`) |
31
+ | `rich-text` | `richText({ headings, links })`: headings from Tina's `overrides.headingLevels` (h2 and h3 only), links on when the toolbar has `link`; Tina's default (no overrides) is h2, h3 and links | caracms's rich text (below) |
32
+ | `displayOnly` | — | dropped |
33
+ | a collection's `isTitle` field | the document's own `title` | copied |
34
+
35
+ Every label and help (`description`) is carried into the scaffolded config. A template's own description (`ui.description`) becomes its block's `help`, cut to a sentence or two; where the site also put it in front of a field's help ("About this block: …"), the field keeps only its own.
36
+
37
+ ## Rich text
38
+
39
+ Tina stores Markdown in a JSON string. Each blank-line-separated chunk becomes caracms blocks:
40
+
41
+ - a line starting `## ` or `### ` → `{ "h2": … }` / `{ "h3": … }`; any other heading level becomes a paragraph, with a warning (caracms never holds an `h1`);
42
+ - runs of `* `, `- ` or `+ ` lines → `{ "ul": [ … ] }`, runs of `1. ` lines → `{ "ol": [ … ] }`; an indented item is flattened into its list, with a warning (caracms has no nested lists);
43
+ - everything else → `{ "p": … }`.
44
+
45
+ Inline, `**bold**`, `*italic*` and `[text](href)` carry over (Tina's `__bold__` and `_italic_` become `**`/`*`). Tina's two forms of a line break, a trailing `\` or two spaces before a newline, become `\n`, and so does a bare newline inside a paragraph. **Tina's escapes are undone** (`A\&M` → `A&M`, `mailto\:` → `mailto:`) except before `*`, `[`, `]` and `\`, the only characters caracms escapes. Units are trimmed, so a rich heading's trailing `\n` goes, and an empty value becomes `[]`.
46
+
47
+ ## Documents and collections
48
+
49
+ - A Tina collection with a list of templates (a section builder) → `kind: 'sections'`: `_template` → `type`, the templates list (`sections`) → `blocks`, its other fields are page fields (stored after `title`, before `blocks`). Any other collection → `kind: 'fields'`.
50
+ - The collection's key and folder are its Tina folder in kebab case (`content/clientLogos` → `client-logos`).
51
+ - Tina's `home.json` (the adapt table's `homeFile`) → `index.json`. Folders under a collection are kept.
52
+ - **Block types** take the adapt table's `blockRenames`, and otherwise go from camelCase to kebab case (`textMedia` → `text-media`).
53
+ - `seo { metaTitle, metaDescription, ogImage, ogImageAlt }` → caracms's `seo { title, description, image }`.
54
+ - **Hidden:** a section builder's boolean `hidden` → `"hidden": true` at the top level of the document, beside `title` and `blocks` (absent means shown). The home page can't be hidden: it's left shown, with a warning.
55
+ - Every file starts with its `$schema` (`../../schemas/<collection>.schema.json`), as `caracms schemas` stamps it.
56
+ - A key in a Tina file that its schema no longer has is left out, with a warning.
57
+
58
+ ## Site Settings
59
+
60
+ | Tina (`siteSettings`) | caracms (`content/site-settings.json`) |
61
+ |---|---|
62
+ | `adminAccess.allow[].entry` | `editors` |
63
+ | `adminBranding { name, accent }` | `brand { name, accent }`. The logo and favicon aren't carried (a warning says so): upload them in the editor |
64
+ | `defaultSeo` | `seo` |
65
+ | `googleTag` | `googleTag` |
66
+ | `formNotifications.<form>[].address` | `formRecipients.<form>`; `--recipients <address>` makes every form's list that one address |
67
+ | `submissionsLink` | dropped (caracms's inbox is in the editor) |
68
+ | everything else | Site Settings' own fields, by the table above |
69
+
70
+ ## Images and files
71
+
72
+ - **Images keep their bucket objects.** The key is the URL's part after `s3://<bucket>/` (`https://img…/unsafe/plain/s3://share-media-…/uploads/photos/a.jpg` → `uploads/photos/a.jpg`). An image URL that isn't in the bucket is left out, with a warning.
73
+ - **`w` and `h`** come from `scripts/tina-media-sizes.json`, read first. A key it lacks is fetched once (its Tina URL, else the bucket with the site's `S3_*` settings), sized by caracms's own sniffer (EXIF orientation included), and added to the file. Commit the file: re-runs then need no network for images.
74
+ - **File links** the Tina site served itself (`/files/<name>`, from `uploads/<name>`; the adapt table's `fileLinks`) become caracms file addresses, `/files/<hex>/<name>`, wherever an address can be: links, buttons, address fields and rich text. `<hex>` is the first 12 hex of the SHA-256 of the source key, so the address is the same on every run. The object is copied in the bucket to `uploads/<hex>/<name>` (the name slugified as caracms's upload names are), and the copy is skipped when that key exists.
75
+
76
+ ## The adapt table
77
+
78
+ At the top of the script, under "ADAPT HERE". Everything else is generic.
79
+
80
+ | Entry | What it's for |
81
+ |---|---|
82
+ | `blockRenames` | the approved block table: a Tina template's caracms key where it isn't the kebab-case rule |
83
+ | `fieldDefaults` | a value Tina's files leave out but the site's mapper code reads as set: a per-block background default that isn't the first option, a toggle that reads as on |
84
+ | `addressFields` | strings that are addresses but aren't named `href` (social links, a privacy link, a YouTube link). Tina's schema doesn't say which strings are links |
85
+ | `headingFields` | text field names that are headings (scaffold only) |
86
+ | `docTitleAlt` | bare images whose alt is the document's title (a client's logo) |
87
+ | `fileLinks` | the Tina site's own file route and the bucket key behind it |
88
+ | `homeFile` | Tina's home page file |