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