caracms 0.3.3 → 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.
Files changed (55) hide show
  1. package/README.md +59 -3
  2. package/bin/caracms.mjs +30 -6
  3. package/bin/gate.mjs +157 -6
  4. package/bin/templates/catch-all.astro.txt +10 -9
  5. package/package.json +1 -1
  6. package/skills/caracms-adopt/SKILL.md +21 -4
  7. package/skills/caracms-blocks/SKILL.md +36 -13
  8. package/skills/caracms-content/SKILL.md +27 -6
  9. package/skills/caracms-tina/SKILL.md +53 -11
  10. package/skills/caracms-tina/field-kinds.md +2 -0
  11. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  12. package/src/cli/validate.ts +51 -10
  13. package/src/code-routes.ts +26 -0
  14. package/src/components/Head.astro +5 -5
  15. package/src/components/RenderDocument.astro +3 -3
  16. package/src/components/sitemap.xml.ts +5 -4
  17. package/src/config.ts +57 -11
  18. package/src/content-schema.ts +5 -2
  19. package/src/content.ts +148 -8
  20. package/src/document.ts +8 -0
  21. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  22. package/src/editor/client/documents/actions.ts +61 -16
  23. package/src/editor/client/documents/dialogs.tsx +104 -15
  24. package/src/editor/client/editor.ts +22 -1
  25. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  26. package/src/editor/client/nav/DocRow.tsx +8 -1
  27. package/src/editor/client/nav/tree.ts +26 -1
  28. package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
  29. package/src/editor/client/pane/parts.tsx +14 -1
  30. package/src/editor/routes/move.ts +1 -1
  31. package/src/editor/server/admin-props.ts +19 -1
  32. package/src/editor/server/create.ts +13 -2
  33. package/src/editor/server/drafts-store.ts +24 -9
  34. package/src/editor/server/move.ts +266 -81
  35. package/src/editor/styles/editor.css +5 -0
  36. package/src/editor/styles/levels/collections.css +2 -0
  37. package/src/editor/styles/levels/m5a-documents.css +15 -0
  38. package/src/editor/types.ts +31 -5
  39. package/src/held.ts +116 -0
  40. package/src/index.ts +4 -2
  41. package/src/live/README.md +3 -2
  42. package/src/live/auth/middleware.ts +11 -0
  43. package/src/live/autosaves.ts +88 -1
  44. package/src/live/cloud.ts +131 -0
  45. package/src/live/drafts-store.ts +122 -87
  46. package/src/live/editor.ts +22 -4
  47. package/src/live/git.ts +63 -12
  48. package/src/live/github.ts +69 -7
  49. package/src/live/mode.ts +42 -6
  50. package/src/live/runtime.ts +18 -2
  51. package/src/redirect-middleware.ts +3 -0
  52. package/src/redirects.ts +32 -1
  53. package/src/relink.ts +40 -9
  54. package/src/serialize.ts +1 -1
  55. 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,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
- | `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`. |
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
package/bin/caracms.mjs CHANGED
@@ -12,20 +12,22 @@
12
12
  // The Node adapter's standalone server sends prerendered pages and static files
13
13
  // before Astro's middleware runs, so a password or a `noindex` header can't be added
14
14
  // there. This serves the adapter's own handler (static files, then Astro) behind the
15
- // gate (gate.mjs): robots.txt, the pre-launch password, then `X-Robots-Tag`.
15
+ // gate (gate.mjs): robots.txt, the pre-launch password, `X-Robots-Tag`, a page beside a
16
+ // folder of the same name, then Site Settings' redirects.
16
17
  //
17
18
  // It runs from the site's root (the directory with `dist/` and `content/`) and reads,
18
19
  // once at start:
19
20
  // PORT (default 8080), HOST (default 0.0.0.0), the site's address (Astro's `site` from
20
21
  // dist/server/caracms-build.json, else SITE_URL, else RAILWAY_PUBLIC_DOMAIN),
21
22
  // CARACMS_PRELAUNCH (`user:password`), CARACMS_CLIENT_IP_HEADER (else the detected
22
- // host's default; with neither, it exits with the fix), and content/site-settings.json → brand.name.
23
+ // host's default; with neither, it exits with the fix), and content/site-settings.json →
24
+ // brand.name and redirects.
23
25
 
24
26
  import { existsSync, readFileSync } from 'node:fs';
25
27
  import { createServer } from 'node:http';
26
- import { join } from 'node:path';
27
- import { pathToFileURL } from 'node:url';
28
- import { createGate, describeClientIpSource, markProcessGated, parsePrelaunch, parseSiteUrl, publicAddress, readBuildInfo, readClientIpSource } from './gate.mjs';
28
+ import { join, relative, resolve } from 'node:path';
29
+ import { fileURLToPath, pathToFileURL } from 'node:url';
30
+ import { createGate, describeClientIpSource, markProcessGated, parsePrelaunch, parseSiteUrl, publicAddress, readBuildInfo, readClientIpSource, readRedirects } from './gate.mjs';
29
31
 
30
32
  const USAGE = `Usage: caracms <command>, from the site's root
31
33
 
@@ -56,6 +58,20 @@ function readBrandName(root) {
56
58
  }
57
59
  }
58
60
 
61
+ /**
62
+ * The adapter's static files folder, as it finds it (@astrojs/node → resolveClientDir):
63
+ * the built server-to-client path from its options, applied to `dist/server` here.
64
+ * `dist/client` when the options don't say.
65
+ */
66
+ function clientDirOf(options, root) {
67
+ const serverDir = join(root, 'dist/server');
68
+ try {
69
+ return resolve(serverDir, relative(fileURLToPath(new URL(options.server)), fileURLToPath(new URL(options.client))));
70
+ } catch {
71
+ return join(root, 'dist/client');
72
+ }
73
+ }
74
+
59
75
  async function start() {
60
76
  const root = process.cwd();
61
77
  const entry = join(root, 'dist/server/entry.mjs');
@@ -87,7 +103,15 @@ async function start() {
87
103
  const publicSite = publicAddress(process.env, readBuildInfo(root));
88
104
  const siteUrl = publicSite?.value;
89
105
  const prelaunch = process.env.CARACMS_PRELAUNCH;
90
- const gate = createGate({ handler: built.handler, clientIpSource: ipSource.source, siteUrl, prelaunch, brandName: readBrandName(root) });
106
+ const gate = createGate({
107
+ handler: built.handler,
108
+ clientIpSource: ipSource.source,
109
+ siteUrl,
110
+ prelaunch,
111
+ brandName: readBrandName(root),
112
+ clientDir: clientDirOf(built.options, root),
113
+ redirects: readRedirects(root),
114
+ });
91
115
 
92
116
  const server = createServer(gate);
93
117
  server.on('error', (err) => fatal(err.message));
package/bin/gate.mjs CHANGED
@@ -12,12 +12,17 @@
12
12
  // 2. The pre-launch password (`CARACMS_PRELAUNCH`), except for the editor and its
13
13
  // API, `/_astro/…` and `/health`.
14
14
  // 3. `X-Robots-Tag` on the way out, kept whatever the adapter writes.
15
- // 4. The adapter's own handler (static files first, then Astro).
15
+ // 4. A page beside a folder of the same name (`services/advisory.html` and
16
+ // `services/advisory/`) is asked for by its file: the adapter would look in the
17
+ // folder for an index.html and answer 404.
18
+ // 5. Site Settings' redirects, on what would be a 404: a prerendered 404 page never
19
+ // reaches Astro's middleware (redirect-middleware.ts, which serves them in dev).
20
+ // 6. The adapter's own handler (static files first, then Astro).
16
21
 
17
22
  import { createHash, timingSafeEqual } from 'node:crypto';
18
- import { readFileSync } from 'node:fs';
23
+ import { readFileSync, statSync } from 'node:fs';
19
24
  import { isIP } from 'node:net';
20
- import { join } from 'node:path';
25
+ import { join, resolve, sep } from 'node:path';
21
26
  import { CLOUDFLARE_IPV4, CLOUDFLARE_IPV6 } from './cloudflare-ips.mjs';
22
27
 
23
28
  /** The header the gate owns. */
@@ -199,6 +204,88 @@ export function parseSiteUrl(value) {
199
204
  return { host: url.host.toLowerCase(), origin: url.origin, indexable };
200
205
  }
201
206
 
207
+ // ── Pages beside a folder, and redirects ────────────────────────────────────────
208
+
209
+ const isFile = (path) => {
210
+ try {
211
+ return statSync(path).isFile();
212
+ } catch {
213
+ return false;
214
+ }
215
+ };
216
+ const isDirectory = (path) => {
217
+ try {
218
+ return statSync(path).isDirectory();
219
+ } catch {
220
+ return false;
221
+ }
222
+ };
223
+
224
+ /**
225
+ * The file to serve for a page whose address is also a folder: with `build.format:
226
+ * 'file'`, `/services/advisory` is `services/advisory.html`, beside a `services/advisory/`
227
+ * folder of the pages under it. The adapter sees the folder, looks for its index.html
228
+ * and answers 404, so the gate asks for the `.html` itself. Null for anything else: a
229
+ * path the two views don't agree on, `/`, a trailing slash, a file extension, a folder
230
+ * with its own index.html, or no such file under `clientDir`.
231
+ */
232
+ export function pageBesideFolder(clientDir, views) {
233
+ const path = views.full;
234
+ if (!clientDir || path === null || views.app !== path || path === '/' || path.endsWith('/')) return null;
235
+ if (/\.[^/]*$/.test(path.slice(path.lastIndexOf('/')))) return null;
236
+ const root = resolve(clientDir);
237
+ const folder = resolve(root, '.' + path);
238
+ if (!folder.startsWith(root + sep)) return null;
239
+ if (!isDirectory(folder) || isFile(join(folder, 'index.html')) || !isFile(folder + '.html')) return null;
240
+ return path + '.html';
241
+ }
242
+
243
+ /**
244
+ * Site Settings' redirects, as the package reads them (src/redirects.ts; kept in step by
245
+ * gate.test.ts): `/path` addresses without a trailing slash; an entry that isn't two of
246
+ * them, goes nowhere or repeats a `from` is dropped.
247
+ */
248
+ export function redirectAddress(value) {
249
+ if (typeof value !== 'string') return null;
250
+ const v = value.trim();
251
+ if (!/^\/(?!\/)[^\s?#]*$/.test(v)) return null;
252
+ const trimmed = v.replace(/\/+$/, '');
253
+ return trimmed === '' ? '/' : trimmed;
254
+ }
255
+
256
+ export function parseRedirects(raw) {
257
+ if (!Array.isArray(raw)) return [];
258
+ const same = (a, b) => a.toLowerCase() === b.toLowerCase();
259
+ const out = [];
260
+ for (const entry of raw) {
261
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
262
+ const from = redirectAddress(entry.from);
263
+ const to = redirectAddress(entry.to);
264
+ if (!from || !to || same(from, to) || out.some((r) => same(r.from, from))) continue;
265
+ out.push({ from, to });
266
+ }
267
+ return out;
268
+ }
269
+
270
+ /** Where a request for `pathname` (as sent) goes, or null: matched decoded, without a trailing slash; letter case counts. */
271
+ export function redirectFor(list, pathname) {
272
+ let path = pathname;
273
+ try {
274
+ path = decodeURI(pathname);
275
+ } catch {}
276
+ path = path.replace(/\/+$/, '') || '/';
277
+ return list.find((r) => r.from === path)?.to ?? null;
278
+ }
279
+
280
+ /** `content/site-settings.json` → `redirects` under `root`, read once at start (the file is the build's). Empty when it's missing or unreadable. */
281
+ export function readRedirects(root) {
282
+ try {
283
+ return parseRedirects(JSON.parse(readFileSync(join(root, 'content/site-settings.json'), 'utf8'))?.redirects);
284
+ } catch {
285
+ return [];
286
+ }
287
+ }
288
+
202
289
  // ── The visitor's IP (Decision 8) ───────────────────────────────────────────────
203
290
 
204
291
  /** The header the gate sets on every request; `clientIp()` (src/live/auth/request.ts) reads only this in production. */
@@ -406,9 +493,9 @@ const sha256 = (buf) => createHash('sha256').update(buf).digest();
406
493
  * The gate, as `(req, res) => void` around the adapter's `handler`.
407
494
  * @param {{ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => unknown,
408
495
  * clientIpSource: string, siteUrl?: string, prelaunch?: string, brandName?: string,
409
- * log?: (line: string) => void }} options
496
+ * clientDir?: string, redirects?: { from: string, to: string }[], log?: (line: string) => void }} options
410
497
  */
411
- export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandName, log = (line) => console.error(line) }) {
498
+ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandName, clientDir, redirects = [], log = (line) => console.error(line) }) {
412
499
  if (!CLIENT_IP_SOURCES.includes(clientIpSource)) throw new Error(`createGate: clientIpSource must be one of ${CLIENT_IP_SOURCES.join(', ')}.`);
413
500
  let warnedMissing = false;
414
501
  const site = parseSiteUrl(siteUrl);
@@ -481,7 +568,24 @@ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandN
481
568
  const robots = guarded ? NOINDEX_NOFOLLOW : indexable ? null : NOINDEX;
482
569
  if (robots) holdHeader(res, ROBOTS_HEADER, robots);
483
570
 
484
- // 4. The adapter.
571
+ if (req.method === 'GET' || req.method === 'HEAD') {
572
+ // 4. A page beside a folder of the same name, by its file.
573
+ // (As sent, plus `.html`: the views are decoded, the request target mustn't be.)
574
+ const file = guarded ? null : pageBesideFolder(clientDir, views);
575
+ const raw = rawPathOf(req.url);
576
+ if (file && raw !== null) {
577
+ req.url = raw + '.html' + req.url.slice(raw.length);
578
+ } else if (!guarded && redirects.length) {
579
+ // 5. A redirect, only where the site answers 404: a real page always wins.
580
+ const to = raw === null ? null : redirectFor(redirects, raw);
581
+ if (to) {
582
+ const query = req.url.indexOf('?');
583
+ redirectOn404(res, to + (query === -1 ? '' : req.url.slice(query)));
584
+ }
585
+ }
586
+ }
587
+
588
+ // 6. The adapter.
485
589
  try {
486
590
  const result = handler(req, res);
487
591
  if (result && typeof result.catch === 'function') result.catch((err) => fail(res, err));
@@ -492,6 +596,53 @@ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandN
492
596
  return gate;
493
597
  }
494
598
 
599
+ /**
600
+ * Turns the response into a 301 to `location` if the handler answers 404, however it
601
+ * writes it (`writeHead`, or a status and an implicit head on the first write or end).
602
+ * The 404's own headers and body are dropped; the held robots header stays.
603
+ */
604
+ function redirectOn404(res, location) {
605
+ const writeHead = res.writeHead.bind(res);
606
+ const write = res.write.bind(res);
607
+ const end = res.end.bind(res);
608
+ let redirected = false;
609
+ const redirect = () => {
610
+ redirected = true;
611
+ for (const name of res.getHeaderNames()) res.removeHeader(name);
612
+ writeHead(301, { Location: location, 'Cache-Control': 'no-cache', 'Content-Length': 0 });
613
+ end();
614
+ };
615
+ const callback = (args) => {
616
+ const cb = args.find((a) => typeof a === 'function');
617
+ if (cb) queueMicrotask(cb);
618
+ };
619
+ const is404 = () => !res.headersSent && res.statusCode === 404;
620
+ res.writeHead = (status, ...rest) => {
621
+ if (redirected) return res;
622
+ if (status === 404 && !res.headersSent) {
623
+ redirect();
624
+ return res;
625
+ }
626
+ return writeHead(status, ...rest);
627
+ };
628
+ res.write = (...args) => {
629
+ if (!redirected && is404()) redirect();
630
+ if (redirected) {
631
+ callback(args);
632
+ return true;
633
+ }
634
+ return write(...args);
635
+ };
636
+ res.end = (...args) => {
637
+ if (!redirected && is404()) redirect();
638
+ if (redirected) {
639
+ callback(args);
640
+ return res;
641
+ }
642
+ return end(...args);
643
+ };
644
+ }
645
+
495
646
  function fail(res, err) {
496
647
  console.error('caracms start: the request failed:', err);
497
648
  if (!res.headersSent) {
@@ -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.3",
3
+ "version": "0.4.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