caracms 0.3.4 → 0.5.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 (66) hide show
  1. package/README.md +73 -3
  2. package/bin/templates/catch-all.astro.txt +10 -9
  3. package/package.json +1 -1
  4. package/skills/caracms-adopt/SKILL.md +21 -4
  5. package/skills/caracms-blocks/SKILL.md +36 -13
  6. package/skills/caracms-content/SKILL.md +27 -6
  7. package/skills/caracms-tina/SKILL.md +53 -11
  8. package/skills/caracms-tina/field-kinds.md +2 -0
  9. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  10. package/src/cli/validate.ts +103 -12
  11. package/src/code-routes.ts +26 -0
  12. package/src/components/Head.astro +5 -5
  13. package/src/components/RenderDocument.astro +3 -3
  14. package/src/components/sitemap.xml.ts +5 -4
  15. package/src/config.ts +57 -11
  16. package/src/content-schema.ts +6 -2
  17. package/src/content.ts +188 -27
  18. package/src/document.ts +13 -2
  19. package/src/editor/README.md +3 -0
  20. package/src/editor/addresses.ts +6 -1
  21. package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
  22. package/src/editor/client/arrange/model.ts +659 -0
  23. package/src/editor/client/arrange/store.ts +335 -0
  24. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  25. package/src/editor/client/documents/actions.ts +75 -19
  26. package/src/editor/client/documents/dialogs.tsx +137 -18
  27. package/src/editor/client/editor.ts +78 -6
  28. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  29. package/src/editor/client/nav/DocRow.tsx +8 -1
  30. package/src/editor/client/nav/tree.ts +26 -1
  31. package/src/editor/client/pane/Pane.tsx +3 -0
  32. package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
  33. package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
  34. package/src/editor/client/pane/parts.tsx +14 -1
  35. package/src/editor/client/shell/Stage.tsx +8 -4
  36. package/src/editor/routes/admin.astro +1 -1
  37. package/src/editor/routes/arrange.ts +17 -0
  38. package/src/editor/routes/move.ts +1 -1
  39. package/src/editor/server/admin-props.ts +24 -4
  40. package/src/editor/server/arrange.ts +245 -0
  41. package/src/editor/server/create.ts +13 -2
  42. package/src/editor/server/drafts-store.ts +55 -10
  43. package/src/editor/server/move.ts +300 -81
  44. package/src/editor/styles/editor.css +5 -0
  45. package/src/editor/styles/levels/arrange.css +152 -0
  46. package/src/editor/styles/levels/collections.css +2 -0
  47. package/src/editor/styles/levels/m5a-documents.css +15 -0
  48. package/src/editor/types.ts +83 -7
  49. package/src/held.ts +116 -0
  50. package/src/index.ts +6 -2
  51. package/src/live/README.md +3 -2
  52. package/src/live/auth/middleware.ts +11 -0
  53. package/src/live/autosaves.ts +88 -1
  54. package/src/live/cloud.ts +131 -0
  55. package/src/live/drafts-store.ts +170 -77
  56. package/src/live/editor.ts +26 -4
  57. package/src/live/git.ts +63 -12
  58. package/src/live/github.ts +69 -7
  59. package/src/live/mode.ts +42 -6
  60. package/src/live/runtime.ts +18 -2
  61. package/src/page-order.ts +253 -0
  62. package/src/redirects.ts +64 -1
  63. package/src/relink.ts +40 -9
  64. package/src/reserved.ts +3 -2
  65. package/src/serialize.ts +18 -1
  66. package/src/slug.ts +15 -4
package/README.md CHANGED
@@ -36,7 +36,8 @@ npm install
36
36
  ```
37
37
 
38
38
  `init` adds the plumbing (the Node adapter, the integration, a minimal `caracms.config.ts`
39
- and page component, the route that serves documents, the schemas) and links caracms's
39
+ and page component, the one catch-all route that serves every document with an address,
40
+ the schemas) and links caracms's
40
41
  skills into `.claude/skills/`. It doesn't touch the site's pages. Bringing them in is the
41
42
  job of the `caracms-adopt` skill, one page at a time.
42
43
 
@@ -58,6 +59,41 @@ Run from the site's root:
58
59
  The skills (`caracms-content`, `caracms-blocks` and `caracms-adopt`, in `skills/`) say how
59
60
  the rest works, matching the installed version.
60
61
 
62
+ ## Collections and addresses
63
+
64
+ A collection that has pages of its own gets its address from exactly one of three places:
65
+
66
+ | Source | In the config | In the content | A document's address |
67
+ |---|---|---|---|
68
+ | **Pageless** | no `urlBase` | none | none: records other pages list (a team, awards, logos) |
69
+ | **A prefix in code** | `urlBase: '/tags/'` | none | `urlBase` + slug: `/tags/<slug>`, with no page at `/tags` |
70
+ | **Held by a page** | `heldBy: 'pages'` | the page's file says `"holds": "journal"` | the page's address + `/` + slug: posts at `/journal/<slug>`, and at `/blog/<slug>` once the page moves |
71
+
72
+ `heldBy` and `urlBase` are exclusive, and a held collection is flat. A page holds at most
73
+ one collection (never Home), and `caracms validate` reports a `holds` that doesn't match a
74
+ `heldBy`.
75
+
76
+ One catch-all route, `src/pages/[...path].astro`, serves all three. It lists what to build
77
+ with `addressedDocuments(site)` from `caracms/content` (`{ collection, path, url }` for
78
+ every public page, leaving out hidden ones and files that can't be opened), and a site that
79
+ renders on demand uses `documentAt(site, pathname)`. A collection needs no route of its
80
+ own; a prefix collection whose base keeps its own route file is left out of the reader,
81
+ and a route with parameters left at a held collection's address is a `validate` error.
82
+
83
+ ## Arranging pages
84
+
85
+ The editor's Pages tree reads Home first, then the pages and groups with pages (or a held
86
+ collection) under them, then the rest, each A–Z. To change that, "Arrange pages…" in the
87
+ tree's header opens a screen at `/admin/arrange`: drag pages to order them, nest one under
88
+ a page or group, make and rename groups, then review every old → new address and apply it
89
+ as one change, with one tick for redirects from the old addresses. Nothing changes until
90
+ Apply, and if the pages change underneath it, Apply refuses and shows the review again.
91
+
92
+ The order is saved in Site Settings as `pageOrder` (each parent's path → its children's
93
+ slugs), so no site field can use that name. It orders the editor's tree, the Inside picker
94
+ and `listDocuments(site, 'pages')`; the site's menus stay lists in Site Settings.
95
+ `caracms validate` warns on an entry that names no page, and `--fix` prunes it.
96
+
61
97
  ## Hosting
62
98
 
63
99
  caracms runs on any host that keeps one Node server running behind HTTPS: Railway,
@@ -90,8 +126,9 @@ These go on the site's server. `caracms init` prints the same list.
90
126
 
91
127
  | Name | Needed | What it's for |
92
128
  |---|---|---|
93
- | `GITHUB_REPO` | required | The site's repository, as owner/name. |
94
- | `CARACMS_GITHUB_TOKEN` | required, or the App | A fine-grained token (`github_pat_…`) on that one repo: Contents read and write, Metadata read (Deployments read is optional). Never `GITHUB_TOKEN`. |
129
+ | `CARACMS_CLOUD_KEY` | instead of the GitHub variables | The site's key from CaraCMS Cloud (`cck_…`). It replaces the token or App and `GITHUB_REPO`: see [CaraCMS Cloud](#caracms-cloud). |
130
+ | `GITHUB_REPO` | required, but not with the cloud key | The site's repository, as owner/name. |
131
+ | `CARACMS_GITHUB_TOKEN` | required, or the App, or the cloud key | A fine-grained token (`github_pat_…`) on that one repo: Contents read and write, Metadata read (Deployments read is optional). Never `GITHUB_TOKEN`. |
95
132
  | `GITHUB_APP_ID` | instead of the token | The GitHub App's ID, for anyone setting up many sites. Set the token or the App, not both. |
96
133
  | `GITHUB_APP_PRIVATE_KEY` | with `GITHUB_APP_ID` | The App's private key. |
97
134
  | `SITE_URL` | required | The site's public address, https://. The same value at build and at run time. |
@@ -275,6 +312,39 @@ site, signs every commit, and isn't tied to a person. Use it for anyone running
275
312
  a handful of sites, or a repo that requires signed commits. Install it on the site's repo
276
313
  only, with the same permissions as the token.
277
314
 
315
+ ### CaraCMS Cloud
316
+
317
+ A site run by CaraCMS can take its GitHub access from CaraCMS Cloud instead of a token or
318
+ an App of its own. The cloud gives the site's server a short-lived token for the site's
319
+ repo, and only that repo, about once an hour.
320
+
321
+ 1. CaraCMS sets the site's repo in CaraCMS Cloud.
322
+ 2. Install the caracms GitHub App on that repo, choosing only that repo. The site's page in
323
+ app.caracms.com says whether it's installed, and links to the App's install page when
324
+ it isn't.
325
+ 3. Make a key for the site in app.caracms.com and set it as `CARACMS_CLOUD_KEY` on the
326
+ site's server.
327
+
328
+ The key replaces `CARACMS_GITHUB_TOKEN` (or `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`)
329
+ and `GITHUB_REPO`: the repo comes from the cloud when the server starts, so setting
330
+ `GITHUB_REPO` beside the key is refused, and so is setting a token or an App as well.
331
+ Everything else in [the variables](#the-variables) is still the site's own: `SITE_URL`,
332
+ `REDIS_URL`, `CARACMS_SECRET`, the sign-in variables, and the image, video and form
333
+ variables. There's no webhook: a check once a minute keeps the editor's Publish status up
334
+ to date.
335
+
336
+ If the cloud says no when the server starts (the App isn't installed on the repo, the
337
+ site has no repo yet, the key was revoked), `/admin` answers 503 with the cloud's own
338
+ sentence, and the public site still serves. The next visit to `/admin` tries again. If it
339
+ says no later, while the editor is open, the editor shows the same sentence until the
340
+ cloud gives a token again. A repo changed in CaraCMS Cloud reaches the site at its next
341
+ start.
342
+
343
+ **On a laptop**, a cloud key turns `astro dev` into live mode on the site's real repo,
344
+ as the App's variables do. Use a key from CaraCMS Cloud's dev deployment there (set its
345
+ address as `CARACMS_CLOUD_URL`), never a production key: the dev deployment only gives
346
+ out tokens for throwaway repos. Leave `CARACMS_CLOUD_URL` unset everywhere else.
347
+
278
348
  ### Behind Cloudflare
279
349
 
280
350
  caracms sees through Cloudflare's proxy (the orange cloud) by itself: when the address
@@ -1,25 +1,26 @@
1
1
  ---
2
2
  /**
3
- * Every document in the Pages collection (content/pages/), nested ones included: the
4
- * file's path is the address. Written by `caracms init`. It serves documents only. The
3
+ * Every document with an address: the Pages collection (content/pages/, nested ones
4
+ * included: the file's path is the address), and every collection a page holds or that
5
+ * has a urlBase of its own. Written by `caracms init`. It serves documents only. The
5
6
  * site's other routes in src/pages/ keep serving their own pages, and their addresses are
6
7
  * reserved: a page is code or a document, never both.
7
8
  */
8
9
  import RenderDocument from 'caracms/components/RenderDocument.astro';
9
- import { listDocuments, readDocument } from 'caracms/content';
10
+ import { addressedDocuments, readDocument } from 'caracms/content';
10
11
  import { site } from 'virtual:caracms/config';
11
12
 
12
13
  export const prerender = true;
13
14
 
14
15
  export async function getStaticPaths() {
15
16
  // A file that can't be opened (invalid JSON, no title, a nested index.json) has no page,
16
- // and nor has a hidden one (or one under it): visitors get the 404 page.
17
- const entries = (await listDocuments(site, 'pages')).filter((e) => !e.warning && !e.hidden);
18
- return entries.map((e) => ({ params: { path: e.path === 'index' ? undefined : e.path }, props: { path: e.path } }));
17
+ // and nor has a hidden one (or one under it, or held by it): visitors get the 404 page.
18
+ const documents = await addressedDocuments(site);
19
+ return documents.map((d) => ({ params: { path: d.url === '/' ? undefined : d.url.slice(1) }, props: { collection: d.collection, path: d.path } }));
19
20
  }
20
21
 
21
- const { path } = Astro.props;
22
- const { document } = await readDocument(site, 'pages', path);
22
+ const { collection, path } = Astro.props;
23
+ const { document } = await readDocument(site, collection, path);
23
24
  ---
24
25
 
25
- <RenderDocument document={document} collection="pages" />
26
+ <RenderDocument document={document} collection={collection} />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caracms",
3
- "version": "0.3.4",
3
+ "version": "0.5.0",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -63,8 +63,9 @@ unapproved, and don't approve it in passing (`pnpm approve` also reviews build s
63
63
  - `caracms.config.ts`: a `pages` collection at `/` (nested), one `text` block, no text
64
64
  styles;
65
65
  - `src/components/caracms/Page.astro` (the page component) and `TextBlock.astro`;
66
- - `src/pages/[...path].astro`, which serves the documents in `content/pages/` and nothing
67
- else, and `src/pages/health.ts`;
66
+ - `src/pages/[...path].astro`, the one catch-all, which serves every document with an
67
+ address (the pages today; a collection added later needs no route of its own, section
68
+ 6) through `addressedDocuments` from `caracms/content`, and `src/pages/health.ts`;
68
69
  - `content/site-settings.json` (its `brand.name` is the package name title-cased: correct
69
70
  it), and `schemas/`;
70
71
  - `astro.config` edits: the Node adapter (standalone), `integrations: [caracms(site)]`,
@@ -304,7 +305,7 @@ something in the site reads the marker, keep it as a `toggle()` field on the col
304
305
  otherwise drop it. Either way, say so in the pull request.
305
306
 
306
307
  **Code routes.** Every route in `src/pages/` other than caracms's `[...path].astro` (and a
307
- collection's own route, below) is the site's code, and its address is reserved: a static
308
+ prefix collection's own route, section 6) is the site's code, and its address is reserved: a static
308
309
  route (`contact.astro`) reserves exactly `/contact`; a route with parameters
309
310
  (`work/[slug].astro`) reserves everything under `/work/`. The editor refuses a page there
310
311
  ("… is served by the site's own code (…), so a page can't go there."), and `validate`
@@ -353,7 +354,23 @@ go only when nothing in the code still uses them.
353
354
  The site's Astro content collections (`src/content.config.ts`) become caracms collections
354
355
  when editors should change them:
355
356
 
356
- - A collection with pages of its own: `kind: 'fields'` with a `urlBase`, and its route.
357
+ - **Pick the address source for each**, one of three:
358
+
359
+ | Source | In the config | In the content | A document's address |
360
+ |---|---|---|---|
361
+ | **Pageless** | no `urlBase` | none | none (a team, an FAQ, logos) |
362
+ | **A prefix in code** | `urlBase: '/tags/'` | none | `urlBase` + slug, with no page at the prefix |
363
+ | **Held by a page** | `heldBy: 'pages'` | the page's file says `"holds": "journal"` | the page's address + `/` + slug |
364
+
365
+ A collection whose old site had a page at its base (a Journal page and the posts under
366
+ it, a Work page and its case studies) is **held** by that page: `heldBy: 'pages'` in
367
+ place of `urlBase`, and `"holds": "<collection>"` in the page's file, after `title`.
368
+ Its documents are served from the page's address and follow the page if it moves. A
369
+ prefix with no page at it (tags) keeps `urlBase`. Neither needs a route:
370
+ `[...path].astro` serves them through `addressedDocuments` (`documentAt` for a site
371
+ that renders on demand). Delete the old collection's `[slug].astro`; one left at a
372
+ held collection's address is a `validate` error. A prefix collection's route file you
373
+ keep is the site's own, and the reader leaves that collection out.
357
374
  - A collection only other pages show (a team, an FAQ, logos): `kind: 'fields'` with **no
358
375
  `urlBase`** and a `component` (the editor's preview of one document). A block shows them
359
376
  with `cms.read.documents('<name>')`, which orders them **by title**: keep the old order
@@ -148,15 +148,32 @@ const { block, cms } = Astro.props;
148
148
 
149
149
  ## Collections
150
150
 
151
+ A collection with pages gets its address from exactly one of three places:
152
+
153
+ | Source | In the config | In the content | A document's address |
154
+ |---|---|---|---|
155
+ | **Pageless** | no `urlBase` | none | none (a team, an FAQ): other pages show them |
156
+ | **A prefix in code** | `urlBase: '/tags/'` | none | `urlBase` + slug, with no page at the prefix |
157
+ | **Held by a page** | `heldBy: 'pages'` | the page's file says `"holds": "journal"` | the page's address + `/` + slug; move the page and they follow |
158
+
151
159
  ```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
160
+ // Held by a page: the Work page's file says "holds": "work", so a case study is at /work/<slug>
161
+ work: { kind: 'fields', folder: 'content/work', heldBy: 'pages', component: 'src/components/caracms/CaseStudy.astro', fields: { … } },
162
+ // A second section builder, held by the Journal page ("holds": "journal"): posts at /journal/<slug>
163
+ journal: { kind: 'sections', folder: 'content/journal', heldBy: 'pages', fields: { date: date() }, blocks: ['text', 'image'] },
164
+ // A prefix in code, with no Tags page: /tags/<slug>
165
+ tags: { kind: 'sections', folder: 'content/tags', urlBase: '/tags/' },
166
+ // A collection with no pages (a team, an FAQ): leave out urlBase and heldBy
155
167
  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
168
  ```
159
169
 
170
+ - **`heldBy` names the nested section builder whose page holds the collection** (`'pages'`),
171
+ and is exclusive with `urlBase`. The held collection is flat (not `nested`), either
172
+ kind. The mount is in the content, not here: one page of `pages` says
173
+ `"holds": "journal"` (a document key after `title` and `hidden`; the editor shows it
174
+ on the page and doesn't change it), one collection per page and one page per
175
+ collection, never Home. Until a page holds it the collection has no addresses; `caracms validate` errors
176
+ on a collection no page holds, two pages holding one, and a `holds` anywhere else.
160
177
  - A `fields` collection's `component` renders one document: it gets
161
178
  `{ document, cms }` (`FieldsDocumentProps` from `caracms/document`), with
162
179
  `document.title` and each field's value; draw fields with `field="<key>"`. The page
@@ -172,14 +189,20 @@ journal: { kind: 'sections', folder: 'content/journal', urlBase: '/journal/', fi
172
189
  gets them), **ordered by title**. For another order, give the documents a `number()`
173
190
  field and sort in the component, or let a page pick and order them with
174
191
  `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.
192
+ - **Routes: one catch-all serves every document with an address.** `src/pages/[...path].astro`
193
+ (`caracms init` writes it) builds from `addressedDocuments(site)` in `caracms/content`,
194
+ which lists `{ collection, path, url }` for every public page of Pages, every held
195
+ collection and every prefix collection, and renders with `RenderDocument` from
196
+ `caracms/components/RenderDocument.astro` and `readDocument`. A site that renders on
197
+ demand asks `documentAt(site, pathname)` for the document at a request path (or
198
+ `null`). Both leave out entries with a `warning` (a file that can't be opened) and
199
+ `hidden` ones (a page hidden from the site, one under it, and the documents of a hidden
200
+ holder): those answer the 404 page. **Don't write a route per collection.** A collection
201
+ doesn't need `src/pages/work/[slug].astro`, and one left at a held collection's address
202
+ is a `validate` error. A prefix collection that keeps its own route file
203
+ (`src/pages/tags/[slug].astro`) is left out of the reader, so the page is never built
204
+ twice. Every other route in `src/pages/` is the site's own code, and its address can't
205
+ hold a document.
183
206
  - Shared blocks (`content/shared/`) and Site Settings are built in; no config.
184
207
 
185
208
  ## Site Settings fields
@@ -23,10 +23,20 @@ a file that's laid out otherwise, and `validate --fix` rewrites it the editor's
23
23
  `content/pages/about.json` is `/about`, `content/pages/services/print.json` is
24
24
  `/services/print`, and `content/pages/index.json` is Home. An `index.json` anywhere
25
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.
26
+ - `kind: 'fields'`: each document is a title plus the collection's `fields`.
27
+ - **A collection with pages gets its address from one of three places:**
28
+
29
+ | Source | In the config | In the content | A document's address |
30
+ |---|---|---|---|
31
+ | Pageless | no `urlBase` | none | none: a team, an FAQ; other pages show them |
32
+ | A prefix in code | `urlBase: '/tags/'` | none | `urlBase` + slug: `content/tags/news.json` is `/tags/news` |
33
+ | Held by a page | `heldBy: 'pages'` | the page's file says `"holds": "journal"` | the page's address + `/` + slug: with `content/pages/journal.json` holding it, `content/journal/a-day.json` is `/journal/a-day` |
34
+
35
+ A held collection's posts follow their page: move the page to `/blog` and they are at
36
+ `/blog/<slug>`. Only the config's `heldBy` and the page's `"holds"` say so, and
37
+ they must agree (`validate` errors when they don't). One catch-all route
38
+ (`src/pages/[...path].astro`) serves all three through `addressedDocuments` from
39
+ `caracms/content`; a collection needs no route of its own.
30
40
  - **Shared blocks** (one block used on several pages): `content/shared/<name>.json`
31
41
  (or the config's `sharedFolder`).
32
42
  - **Site Settings**: `content/site-settings.json`, one file.
@@ -65,6 +75,10 @@ A page (`sections`):
65
75
  - `{ "shared": "<name>" }` places the shared block `content/shared/<name>.json` there.
66
76
  - If the collection has `fields` of its own (page fields), they're top-level keys after
67
77
  `title` and before `blocks`.
78
+ - A page that holds a collection says so with `"holds": "<collection key>"`, after `title`
79
+ (and `hidden`) and before the page fields, `seo` and `blocks`. Only a page of the
80
+ collection the held collection's `heldBy` names can hold one, never Home, one page per
81
+ collection and one collection per page. Don't put it anywhere else.
68
82
  - An optional `"seo": { "title", "description", "image" }` sets the page's search and
69
83
  share text; leave it out to use Site Settings' defaults.
70
84
 
@@ -152,8 +166,15 @@ rename or delete the file, fix every `href` and `reference` that pointed at it (
152
166
  `content/` for the old address and path), and for a page that was public add
153
167
  `{ "from": "/old-address", "to": "/new-address" }` to Site Settings' `redirects`.
154
168
 
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`.
169
+ **A page that holds a collection takes its documents with it**, and so does every page
170
+ under it: in the editor, Change address… moves them all and adds a redirect for each old
171
+ address. By hand, the held documents' files don't move (their address is the page's plus
172
+ the slug), but every `href` to each of them changes and each old address that was public
173
+ needs its own `redirects` entry. A holder can't be deleted while it holds: take its
174
+ `"holds"` line out first, or move the documents to another page.
175
+
176
+ A page can't go at an address the site's own code serves (a route in `src/pages/`), at
177
+ a held document's address (`/journal/a-day`), or at `/admin`, `/_caracms` or `/files`.
157
178
 
158
179
  ## Choosing blocks for a page
159
180
 
@@ -92,7 +92,8 @@ Copy `node_modules/caracms/skills/caracms-tina/tina-to-caracms.mjs` into the sit
92
92
  the approved `blockRenames`, `fieldDefaults` (values Tina's files leave out but the
93
93
  template's mapper code read as set: a toggle that defaulted on, a per-block background
94
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
95
+ the file-link pattern, Tina's home file and `holders` (which page holds which collection;
96
+ the only source of holding, so an empty table holds nothing). **`addressFields` matters:** Tina's schema has
96
97
  no marker for a link field (a custom component serialises to `ui: {}`), so only strings
97
98
  named `href` are known to be addresses; list every other one. Everything below the table is generic and
98
99
  driven by Tina's generated schema; never add per-block code. If a block needs something
@@ -116,7 +117,11 @@ node scripts/tina-to-caracms.mjs --from ../share-web-main --scaffold-config
116
117
  - `--scaffold-config` writes a starting `caracms.config.ts` from Tina's schema, with
117
118
  Tina's labels and help. **Once.** After that the config and the block components are
118
119
  the site's own, hand-maintained, and a normal run never touches them (section 4 says
119
- which options only you can add).
120
+ which options only you can add). A collection the adapt table's `holders` names
121
+ (`games: 'games'`) is **held by that page**: the scaffold writes `heldBy: 'pages'` and
122
+ no `urlBase`, and every run writes `"holds": "games"` on the page (section 6). A
123
+ collection not in the table keeps `urlBase`. The script prints a hint for each sections
124
+ collection that has a same-named page but isn't in the table, saying what to add.
120
125
  - `--recipients <address>` replaces every form's recipients in Site Settings with one
121
126
  address. **Always pass it for a branch environment**: the converted Site Settings name
122
127
  the client, and a test submission would email them. The run at the switch leaves it out.
@@ -140,7 +145,8 @@ caracms's (Tina's escapes such as `A\&M` and `mailto\:` undone), an image and it
140
145
  sibling → one media value, a Mux clip → a video value, `linkLabel` + `href` → one
141
146
  `link()`, references → document paths, Site Settings (`formNotifications` →
142
147
  `formRecipients`, `adminBranding` → `brand`, `adminAccess` → `editors`, `defaultSeo` →
143
- `seo`), file links → caracms file addresses, and Tina's `hidden` → `"hidden": true`
148
+ `seo`), file links → caracms file addresses, Tina's `hidden` → `"hidden": true`
149
+ (section 6), and the page that sits at a collection's base → `"holds": "<collection>"`
144
150
  (section 6).
145
151
 
146
152
  ## 3. Tina's pieces and what replaces each
@@ -150,7 +156,7 @@ sibling → one media value, a Mux clip → a video value, `linkLabel` + `href`
150
156
  | `tina/`, `tina/__generated__/`, Tina's dependencies | yes | `caracms.config.ts` |
151
157
  | `public/admin/`, `public/admin-previews/` | yes | `/admin` (caracms's editor) |
152
158
  | `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) |
159
+ | `src/pages/[...slug].astro` and the collection routes reading Tina (`blog/[slug].astro`) | yes | the one catch-all (section 6) |
154
160
  | `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
161
  | `Sections.astro` / `PageView` | yes | the page component and `<Blocks>` |
156
162
  | `data-tina-field` on every element | yes | caracms's markers, added by `Block`, `Text`, `RichText`, `Media` in a preview only |
@@ -301,17 +307,53 @@ block, when the template put it there: section links jump to those ids.
301
307
 
302
308
  ## 6. Routes, hidden pages and the 404
303
309
 
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
+ A collection with pages gets its address from one of three places, and the port picks
311
+ one per collection:
312
+
313
+ | Source | In the config | In the content | A document's address | In the template |
314
+ |---|---|---|---|---|
315
+ | **Pageless** | no `urlBase` | none | none | a team, awards, logos: other pages list them |
316
+ | **A prefix in code** | `urlBase: '/tags/'` | none | `urlBase` + slug | `/tags/<slug>`, with no Tags page |
317
+ | **Held by a page** | `heldBy: 'pages'` | the page says `"holds": "posts"` | the page's address + `/` + slug | the Blog page and its posts: `/blog/<slug>` |
318
+
319
+ **Don't write a route per collection.** A template's Blog page **holds** its posts
320
+ collection: the posts are at the page's address, so they follow the page wherever it
321
+ moves. A prefix with no page (tags) keeps `urlBase`. `init` writes one catch-all
322
+ (`src/pages/[...path].astro`, once Tina's `[...slug].astro` is gone: it refuses while
323
+ another route catches the top of the site), and it serves pages, held documents and
324
+ prefix documents alike, through the reader from `caracms/content`:
310
325
 
311
326
  ```ts
312
- const entries = (await listDocuments(site, 'pages')).filter((e) => !e.warning && !e.hidden);
327
+ import { addressedDocuments } from 'caracms/content';
328
+
329
+ export async function getStaticPaths() {
330
+ const documents = await addressedDocuments(site); // { collection, path, url }[]
331
+ return documents.map((d) => ({ params: { path: d.url === '/' ? undefined : d.url.slice(1) }, props: { collection: d.collection, path: d.path } }));
332
+ }
313
333
  ```
314
334
 
335
+ The reader already leaves out **`e.warning`** (a file that can't be opened) and
336
+ **`e.hidden`** (a hidden page, one under it, and the documents of a hidden holder). A
337
+ site that renders on demand asks `documentAt(site, pathname)` instead: the document at a
338
+ request path, or `null`.
339
+
340
+ **The script holds only what the adapt table says.** `holders: { games: 'games' }` (or
341
+ `posts: 'blog'` where the folder isn't named like the page) makes `/games/` the page's
342
+ address and the collection's; with no entry nothing is held, so a site ported before this
343
+ keeps its `urlBase`. For each sections collection that has a same-named page and no entry,
344
+ the script prints a hint with the line to add (then re-run with `--scaffold-config
345
+ --force`); a fields collection is never suggested. Home never
346
+ holds a collection, one page holds one collection, and a held collection is flat;
347
+ `validate` says when any of that is wrong. A `holds` added to a page by hand is
348
+ lost on the next run (`content/` is written whole), so the table is where it belongs.
349
+
350
+ **Delete the template's collection routes.** A route with parameters left at a held base
351
+ (`src/pages/blog/[slug].astro`) is a `validate` error: the catch-all serves those
352
+ documents. A route at a prefix collection's base (`src/pages/tags/[slug].astro`) is the
353
+ site's own for that collection, and the reader leaves the collection out, so keep it only
354
+ for a page the catch-all can't draw (a listing built from logic); otherwise delete it
355
+ and let the catch-all serve the prefix too.
356
+
315
357
  **Hidden pages:** Tina's `hidden` becomes `"hidden": true` at the top of the document,
316
358
  after its title. A hidden page isn't served (visitors get the site's own `404.astro`),
317
359
  isn't in the sitemap, isn't offered by the link picker, and isn't in a block's
@@ -52,6 +52,7 @@ Inline, `**bold**`, `*italic*` and `[text](href)` carry over (Tina's `__bold__`
52
52
  - **Block types** take the adapt table's `blockRenames`, and otherwise go from camelCase to kebab case (`textMedia` → `text-media`).
53
53
  - `seo { metaTitle, metaDescription, ogImage, ogImageAlt }` → caracms's `seo { title, description, image }`.
54
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
+ - **Holds:** the page the adapt table's `holders` names for a collection (`games: 'games'`, `posts: 'blog'`; the table is the only source, with no default from names) gets `"holds": "<collection>"` after `title` and `hidden`, and the scaffolded config gives that collection `heldBy: 'pages'` in place of `urlBase`. The home page never holds one. A table entry whose page doesn't exist is a warning. For each sections collection with a same-named page that isn't in the table, the script prints a hint line saying what to add (a fields collection is never suggested).
55
56
  - Every file starts with its `$schema` (`../../schemas/<collection>.schema.json`), as `caracms schemas` stamps it.
56
57
  - A key in a Tina file that its schema no longer has is left out, with a warning.
57
58
 
@@ -86,3 +87,4 @@ At the top of the script, under "ADAPT HERE". Everything else is generic.
86
87
  | `docTitleAlt` | bare images whose alt is the document's title (a client's logo) |
87
88
  | `fileLinks` | the Tina site's own file route and the bucket key behind it |
88
89
  | `homeFile` | Tina's home page file |
90
+ | `holders` | a collection key → the page file that holds it (`games: 'games'`, `posts: 'blog'`); the only source of holding, so a collection not listed keeps its `urlBase` |
@@ -19,6 +19,12 @@
19
19
  * Tina's labels and help, over `caracms init`'s starting config. It refuses any other
20
20
  * existing config unless `--force`. Do it once: after that the config and the block
21
21
  * components are the site's own, hand-maintained, and a normal run never touches them.
22
+ * A collection whose template has a page at its base (`pages/games.json` for a `games`
23
+ * collection) is held by that page only if the adapt table `SITE.holders` says so
24
+ * (`games: 'games'`): then the scaffold writes `heldBy: 'pages'` in place of `urlBase`,
25
+ * and every run writes `"holds": "games"` on the page. Not in the table: the collection
26
+ * keeps `urlBase: '/games/'` (a prefix in code), and the script only prints a hint line
27
+ * for each sections collection that has a same-named page, saying what to add.
22
28
  *
23
29
  * WHAT IT OWNS: `content/`, nothing else. Each run deletes `content/` and writes it again
24
30
  * from Tina's files, so the same input gives byte-identical output, and a run on newer
@@ -54,6 +60,7 @@ import { pathToFileURL } from 'node:url';
54
60
  * @property {string[]} docTitleAlt
55
61
  * @property {{ pattern: RegExp, sourceKey: (name: string) => string }} fileLinks
56
62
  * @property {string} homeFile
63
+ * @property {Record<string, string>} holders
57
64
  */
58
65
 
59
66
  /** @type {SiteTable} */
@@ -87,6 +94,17 @@ export const SITE = {
87
94
  fileLinks: { pattern: /^\/files\/([^/?#]+)$/, sourceKey: (name) => `uploads/${name}` },
88
95
  /** Tina's home page file (it becomes `index`). */
89
96
  homeFile: 'home',
97
+ /**
98
+ * The collections held by a page (collection key → page file name, without `.json`):
99
+ * this table is the only source, with no default from names (`games: 'games'`,
100
+ * `posts: 'blog'`). The scaffold's `heldBy` and every run's `"holds"` both come from it, so
101
+ * a site ported without an entry is never given a `holds` its config doesn't expect.
102
+ * The script suggests candidates: a sections collection with a same-named page that isn't
103
+ * listed here is printed as a line saying what to add.
104
+ */
105
+ holders: {
106
+ // posts: 'blog',
107
+ },
90
108
  };
91
109
 
92
110
  // ── The template's conventions (the same in every site made from it) ────────────────────
@@ -516,6 +534,7 @@ export function layout(value, shape, depth = 0) {
516
534
  * or fields) with its compiled fields, the blocks by caracms key, and Site Settings.
517
535
  */
518
536
  export function readSchema(schema, site = SITE) {
537
+ /** @type {Record<string, any>} */
519
538
  const collections = {};
520
539
  const blocks = new Map(); // key → { template, label, specs }
521
540
  const warnings = [];
@@ -554,7 +573,9 @@ export function readSchema(schema, site = SITE) {
554
573
  }
555
574
  const settings = schema.collections.find((c) => c.name === SETTINGS);
556
575
  const settingsSpecs = settings ? compileFields(settings.fields.filter((f) => !OWN_SETTINGS.has(f.name)), SETTINGS, site) : [];
557
- return { collections, blocks, settings, settingsSpecs, warnings };
576
+ /** @type {string[]} Hint lines `findHolders` fills in. */
577
+ const holderCandidates = [];
578
+ return { collections, blocks, settings, settingsSpecs, warnings, holderCandidates };
558
579
  }
559
580
  /** A Tina template's own description (`ui.description`, or `description`), or ''. */
560
581
  const templateDescription = (t) => String(t.ui?.description ?? t.description ?? '').trim();
@@ -618,12 +639,53 @@ const seoOf = (raw, conv, where) => {
618
639
  };
619
640
  const SEO_SHAPE = { kind: 'object', keys: new Map([['image', INLINE]]) };
620
641
 
642
+ /**
643
+ * The collections held by a page (collections under pages). The source is the adapt table
644
+ * `SITE.holders` (collection key → page file name) and nothing else: a collection the table
645
+ * names, whose page exists in the pages folder, is held by that page. The scaffold writes
646
+ * `heldBy: 'pages'` (and no `urlBase`) for it, and the conversion writes `"holds": "<key>"`
647
+ * on the page, both from this one decision. A collection the table doesn't name is a prefix
648
+ * in code and keeps its `urlBase` (so a site ported before this change, with an empty table,
649
+ * comes out the same). Sets `holder` (the page's file name) on each collection it finds;
650
+ * Home never holds one. A table entry naming a page that doesn't exist is a warning in
651
+ * `read.warnings`. `read.holderCandidates` lists the *sections* collections that aren't in
652
+ * the table but have a same-named page, as hint lines (a fields collection is never
653
+ * suggested). Returns the page name → collection key map.
654
+ */
655
+ export function findHolders(read, from, site = SITE) {
656
+ const holders = new Map();
657
+ read.holderCandidates.length = 0;
658
+ const pages = Object.values(read.collections).find((c) => c.key === 'pages' && c.sections);
659
+ if (!pages) return holders;
660
+ const names = new Set(jsonFiles(path.join(from, pages.tina.path)).map((f) => f.replace(/\.json$/, '')));
661
+ for (const c of Object.values(read.collections)) {
662
+ if (c === pages || c.key === 'pages') continue;
663
+ const page = site.holders?.[c.key];
664
+ if (page === undefined) {
665
+ if (c.sections && names.has(c.key) && c.key !== site.homeFile && c.key !== 'index') {
666
+ read.holderCandidates.push(`${c.key} has a page at ${pages.tina.path}/${c.key}.json: to have that page hold it, add ${c.key}: '${c.key}' to SITE.holders, then re-run with --scaffold-config --force`);
667
+ }
668
+ continue;
669
+ }
670
+ if (!names.has(page)) {
671
+ read.warnings.push(`SITE.holders: ${c.key} is held by the page "${page}", but there's no ${pages.tina.path}/${page}.json, so it isn't held`);
672
+ continue;
673
+ }
674
+ if (page === site.homeFile || page === 'index' || holders.has(page)) continue;
675
+ c.holder = page;
676
+ holders.set(page, c.key);
677
+ }
678
+ return holders;
679
+ }
680
+
621
681
  /**
622
682
  * Convert every document. Returns `{ files: Map<relPath, text>, conv }`; nothing is written.
623
683
  * `recipients`: the `--recipients` address, or undefined.
684
+ * @param {{ from: string, schema: any, slugify: any, site?: SiteTable, recipients?: string }} args
624
685
  */
625
686
  export function convertAll({ from, schema, slugify, site = SITE, recipients }) {
626
687
  const read = readSchema(schema, site);
688
+ const holders = findHolders(read, from, site);
627
689
  const conv = converter({ collections: read.collections, slugify, site });
628
690
  conv.warnings.push(...read.warnings);
629
691
  const docs = []; // { rel, data, shape }
@@ -643,6 +705,8 @@ export function convertAll({ from, schema, slugify, site = SITE, recipients }) {
643
705
  if (name === 'index') conv.warn(where, "the home page can't be hidden in caracms, so it's shown");
644
706
  else data.hidden = true;
645
707
  }
708
+ // The page that holds a collection says so, after its title and hidden (the editor's order).
709
+ if (c.key === 'pages' && holders.has(name)) data.holds = holders.get(name);
646
710
  const own = Object.fromEntries(Object.entries(raw).filter(([k]) => ![c.titleField, c.blocksField, ...(c.hasSeo ? ['seo'] : []), ...(c.hasHidden ? ['hidden'] : [])].includes(k)));
647
711
  Object.assign(data, conv.object(c.specs, own, where, doc));
648
712
  if (c.sections) {
@@ -867,12 +931,13 @@ export function scaffoldConfig(read) {
867
931
  const cols = Object.values(read.collections).map((c) => {
868
932
  const parts = [`label: ${q(c.label)}`];
869
933
  if (c.sections) {
870
- parts.push(`kind: 'sections'`, `folder: ${q(`content/${c.key}`)}`, `urlBase: ${q(c.key === 'pages' ? '/' : `/${c.key}/`)}`);
934
+ // A page at the collection's base holds it (heldBy); with no page the prefix stays in code (urlBase).
935
+ parts.push(`kind: 'sections'`, `folder: ${q(`content/${c.key}`)}`, c.holder ? `heldBy: 'pages'` : `urlBase: ${q(c.key === 'pages' ? '/' : `/${c.key}/`)}`);
871
936
  if (c.key === 'pages') parts.push('nested: true');
872
937
  if (c.blockKeys.length !== allBlocks.length) parts.push(`blocks: ${q(c.blockKeys)}`);
873
938
  if (c.specs.length) parts.push(`fields: ${fieldsConfig(c.specs, read, used, '\t\t\t')}`);
874
939
  } else {
875
- parts.push(`kind: 'fields'`, `folder: ${q(`content/${c.key}`)}`, `component: ${q(`src/components/caracms/${pascal(c.key)}.astro`)}`, `fields: ${fieldsConfig(c.specs, read, used, '\t\t\t')}`);
940
+ parts.push(`kind: 'fields'`, `folder: ${q(`content/${c.key}`)}`, ...(c.holder ? [`heldBy: 'pages'`] : []), `component: ${q(`src/components/caracms/${pascal(c.key)}.astro`)}`, `fields: ${fieldsConfig(c.specs, read, used, '\t\t\t')}`);
876
941
  }
877
942
  return `\t\t${/^[a-z][a-z0-9]*$/.test(c.key) ? c.key : q(c.key)}: {\n\t\t\t${parts.join(',\n\t\t\t')},\n\t\t},`;
878
943
  });
@@ -993,6 +1058,9 @@ async function main() {
993
1058
  for (const f of [...conv.files.values()].sort((a, b) => a.key.localeCompare(b.key))) console.log(` ${f.sourceKey} → ${f.key}`);
994
1059
  }
995
1060
  }
1061
+ const held = Object.values(read.collections).filter((c) => c.holder);
1062
+ if (held.length) console.log(`Held by a page: ${held.map((c) => `${c.key} (the ${c.holder} page says "holds": "${c.key}")`).join(', ')}.`);
1063
+ for (const line of read.holderCandidates ?? []) console.log(`Hint: ${line}.`);
996
1064
  if (args.recipients) console.log(`Form recipients: every form's are ${args.recipients} (--recipients).`);
997
1065
  if (conv.warnings.length) console.log(`Warnings:\n ${conv.warnings.join('\n ')}`);
998
1066
  return 0;