caracms 0.3.4 → 0.4.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.
- package/README.md +59 -3
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +51 -10
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +5 -2
- package/src/content.ts +148 -8
- package/src/document.ts +8 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +61 -16
- package/src/editor/client/documents/dialogs.tsx +104 -15
- package/src/editor/client/editor.ts +22 -1
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +19 -1
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +24 -9
- package/src/editor/server/move.ts +266 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +31 -5
- package/src/held.ts +116 -0
- package/src/index.ts +4 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +122 -87
- package/src/live/editor.ts +22 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/redirects.ts +32 -1
- package/src/relink.ts +40 -9
- package/src/serialize.ts +1 -1
- 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
|
|
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,27 @@ 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
|
+
|
|
61
83
|
## Hosting
|
|
62
84
|
|
|
63
85
|
caracms runs on any host that keeps one Node server running behind HTTPS: Railway,
|
|
@@ -90,8 +112,9 @@ These go on the site's server. `caracms init` prints the same list.
|
|
|
90
112
|
|
|
91
113
|
| Name | Needed | What it's for |
|
|
92
114
|
|---|---|---|
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
115
|
+
| `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). |
|
|
116
|
+
| `GITHUB_REPO` | required, but not with the cloud key | The site's repository, as owner/name. |
|
|
117
|
+
| `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
118
|
| `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
119
|
| `GITHUB_APP_PRIVATE_KEY` | with `GITHUB_APP_ID` | The App's private key. |
|
|
97
120
|
| `SITE_URL` | required | The site's public address, https://. The same value at build and at run time. |
|
|
@@ -275,6 +298,39 @@ site, signs every commit, and isn't tied to a person. Use it for anyone running
|
|
|
275
298
|
a handful of sites, or a repo that requires signed commits. Install it on the site's repo
|
|
276
299
|
only, with the same permissions as the token.
|
|
277
300
|
|
|
301
|
+
### CaraCMS Cloud
|
|
302
|
+
|
|
303
|
+
A site run by CaraCMS can take its GitHub access from CaraCMS Cloud instead of a token or
|
|
304
|
+
an App of its own. The cloud gives the site's server a short-lived token for the site's
|
|
305
|
+
repo, and only that repo, about once an hour.
|
|
306
|
+
|
|
307
|
+
1. CaraCMS sets the site's repo in CaraCMS Cloud.
|
|
308
|
+
2. Install the caracms GitHub App on that repo, choosing only that repo. The site's page in
|
|
309
|
+
app.caracms.com says whether it's installed, and links to the App's install page when
|
|
310
|
+
it isn't.
|
|
311
|
+
3. Make a key for the site in app.caracms.com and set it as `CARACMS_CLOUD_KEY` on the
|
|
312
|
+
site's server.
|
|
313
|
+
|
|
314
|
+
The key replaces `CARACMS_GITHUB_TOKEN` (or `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`)
|
|
315
|
+
and `GITHUB_REPO`: the repo comes from the cloud when the server starts, so setting
|
|
316
|
+
`GITHUB_REPO` beside the key is refused, and so is setting a token or an App as well.
|
|
317
|
+
Everything else in [the variables](#the-variables) is still the site's own: `SITE_URL`,
|
|
318
|
+
`REDIS_URL`, `CARACMS_SECRET`, the sign-in variables, and the image, video and form
|
|
319
|
+
variables. There's no webhook: a check once a minute keeps the editor's Publish status up
|
|
320
|
+
to date.
|
|
321
|
+
|
|
322
|
+
If the cloud says no when the server starts (the App isn't installed on the repo, the
|
|
323
|
+
site has no repo yet, the key was revoked), `/admin` answers 503 with the cloud's own
|
|
324
|
+
sentence, and the public site still serves. The next visit to `/admin` tries again. If it
|
|
325
|
+
says no later, while the editor is open, the editor shows the same sentence until the
|
|
326
|
+
cloud gives a token again. A repo changed in CaraCMS Cloud reaches the site at its next
|
|
327
|
+
start.
|
|
328
|
+
|
|
329
|
+
**On a laptop**, a cloud key turns `astro dev` into live mode on the site's real repo,
|
|
330
|
+
as the App's variables do. Use a key from CaraCMS Cloud's dev deployment there (set its
|
|
331
|
+
address as `CARACMS_CLOUD_URL`), never a production key: the dev deployment only gives
|
|
332
|
+
out tokens for throwaway repos. Leave `CARACMS_CLOUD_URL` unset everywhere else.
|
|
333
|
+
|
|
278
334
|
### Behind Cloudflare
|
|
279
335
|
|
|
280
336
|
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
|
|
4
|
-
* file's path is the address
|
|
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 {
|
|
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
|
|
18
|
-
return
|
|
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,
|
|
22
|
+
const { collection, path } = Astro.props;
|
|
23
|
+
const { document } = await readDocument(site, collection, path);
|
|
23
24
|
---
|
|
24
25
|
|
|
25
|
-
<RenderDocument document={document} collection=
|
|
26
|
+
<RenderDocument document={document} collection={collection} />
|
package/package.json
CHANGED
|
@@ -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
|
|
67
|
-
|
|
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,
|
|
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
|
-
-
|
|
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
|
-
//
|
|
153
|
-
work: { kind: 'fields', folder: 'content/work',
|
|
154
|
-
// A
|
|
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
|
-
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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`.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
156
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|