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
@@ -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;
@@ -24,7 +24,8 @@
24
24
  import { readFileSync, writeFileSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  import type { ResolvedCollection, ResolvedSite } from '../config.ts';
27
- import { codeRouteAt, codeRouteMessage, shadowingMessage, shadowingRoutes, type CodeRoute } from '../code-routes.ts';
27
+ import { codeRouteAt, codeRouteMessage, leftBehindMessage, leftBehindRoutes, shadowingMessage, shadowingRoutes, type CodeRoute } from '../code-routes.ts';
28
+ import { heldBases } from '../held.ts';
28
29
  import { pagesRoutes, readCodeRoutes, srcDirOf } from '../code-routes-disk.ts';
29
30
  import { listDocuments, type DocumentEntry } from '../content.ts';
30
31
  import { own } from '../document.ts';
@@ -308,9 +309,10 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
308
309
  if (raw.title !== undefined && typeof raw.title !== 'string') error(c, 'title', `Expected text (a string), found ${what(raw.title)}.`);
309
310
  switch (collection.kind) {
310
311
  case 'sections': {
311
- checkFields(c, own, raw, '', `a ${collection.label} page`, 0, [SCHEMA_KEY, 'title', 'hidden', 'blocks', 'seo']);
312
+ checkFields(c, own, raw, '', `a ${collection.label} page`, 0, [SCHEMA_KEY, 'title', 'hidden', 'holds', 'blocks', 'seo']);
312
313
  // M9 Decision 7.
313
314
  if (raw.hidden !== undefined && raw.hidden !== true) error(c, 'hidden', `Expected true (hidden from the site) or no "hidden" at all, found ${what(raw.hidden)}.`);
315
+ if (raw.holds !== undefined) checkHolds(c, collection, raw.holds);
314
316
  if (!Object.hasOwn(raw, 'blocks')) error(c, 'blocks', 'This page has no "blocks". Every page has a list of blocks, even an empty one ([]).');
315
317
  else if (!Array.isArray(raw.blocks)) error(c, 'blocks', `Expected a list of blocks, found ${what(raw.blocks)}.`);
316
318
  else raw.blocks.forEach((b, i) => checkBlock(c, b, at('blocks', i), collection));
@@ -318,16 +320,19 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
318
320
  return;
319
321
  }
320
322
  case 'fields':
321
- checkFields(c, own, raw, '', `a ${collection.label} document`, 0, [SCHEMA_KEY, 'title', 'seo']);
323
+ checkFields(c, own, raw, '', `a ${collection.label} document`, 0, [SCHEMA_KEY, 'title', 'seo', 'holds']);
324
+ if (raw.holds !== undefined) error(c, 'holds', HOLDS_ON_A_PAGE);
322
325
  checkSeo(c, raw.seo, 'seo');
323
326
  return;
324
327
  case 'shared':
325
- unknownKeys(c, raw, [SCHEMA_KEY, 'title', 'block'], '', 'a shared block');
328
+ unknownKeys(c, raw, [SCHEMA_KEY, 'title', 'block', 'holds'], '', 'a shared block');
329
+ if (raw.holds !== undefined) error(c, 'holds', HOLDS_ON_A_PAGE);
326
330
  if (!Object.hasOwn(raw, 'block')) error(c, 'block', 'This shared block has no "block".');
327
331
  else checkBlock(c, raw.block, 'block', null);
328
332
  return;
329
333
  case 'settings': {
330
- checkFields(c, own, raw, '', 'Site Settings', 0, [SCHEMA_KEY, 'title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects']);
334
+ checkFields(c, own, raw, '', 'Site Settings', 0, [SCHEMA_KEY, 'title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects', 'holds']);
335
+ if (raw.holds !== undefined) error(c, 'holds', HOLDS_ON_A_PAGE);
331
336
  if (raw.editors !== undefined && !strings(raw.editors)) error(c, 'editors', `Expected a list of email addresses, found ${what(raw.editors)}.`);
332
337
  if (raw.brand !== undefined) {
333
338
  if (!isObject(raw.brand)) error(c, 'brand', `Expected { "name", "accent", "logo", "icon" }, found ${what(raw.brand)}.`);
@@ -354,6 +359,23 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
354
359
  }
355
360
  }
356
361
 
362
+ /** Collections under pages (Decision 13): `"holds"` on anything but a page. */
363
+ const HOLDS_ON_A_PAGE = '"holds" is for a page: it says which collection\'s documents sit under the page\'s address. Only a page of the collection that collection\'s heldBy names can hold one. Take it out.';
364
+
365
+ /**
366
+ * Decision 13: a page's `"holds"` names a collection whose `heldBy` is this page's collection.
367
+ * Home holding one, and two pages holding one, are the collection's problems (held.ts), reported on their files by validateSite.
368
+ */
369
+ function checkHolds(c: Context, collection: ResolvedCollection, holds: unknown) {
370
+ if (typeof holds !== 'string') return error(c, 'holds', `Expected a collection's key (text), like "journal", found ${what(holds)}.`);
371
+ const held = own(c.site.collections, holds);
372
+ if (!held || !held.heldBy) {
373
+ const heldOnes = Object.values(c.site.collections).filter((h) => h.heldBy === collection.name).map((h) => `"${h.name}"`);
374
+ return error(c, 'holds', `"holds" names "${holds}", which isn't a collection a page holds${heldOnes.length ? ` (a ${collection.label} page can hold ${heldOnes.join(' or ')})` : ''}. A collection is held when its config says heldBy: '${collection.name}'.`);
375
+ }
376
+ if (held.heldBy !== collection.name) error(c, 'holds', `${held.label} is held by a page of ${c.site.collections[held.heldBy]?.label ?? held.heldBy} (its heldBy), not of ${collection.label}. Move "holds" to a page there.`);
377
+ }
378
+
357
379
  /** Every finding for the site's content. `codeRoutes`: the site's own (default: read from its src/pages). */
358
380
  export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[] = readCodeRoutes(site.root, srcDirOf(site.root), Object.values(site.collections))): Promise<Report> {
359
381
  const report: Report = { errors: [], warnings: [] };
@@ -373,10 +395,25 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
373
395
  }
374
396
 
375
397
  // A second route with parameters at a collection's base hides its documents (code-routes.ts → one per base).
376
- for (const s of shadowingRoutes(pagesRoutes(site.root, srcDirOf(site.root)), Object.values(site.collections))) {
398
+ const routes = pagesRoutes(site.root, srcDirOf(site.root));
399
+ for (const s of shadowingRoutes(routes, Object.values(site.collections))) {
377
400
  report.errors.push({ file: s.route.file, path: '', message: shadowingMessage(s) });
378
401
  }
379
402
 
403
+ // Collections under pages (Decision 13): a held collection with no addresses, its sentence on each file it names.
404
+ const bases = heldBases(site.collections, entries);
405
+ const held: { name: string; label: string; base: string; holder: string }[] = [];
406
+ for (const base of Object.values(bases)) {
407
+ const collection = site.collections[base.collection]!;
408
+ if (base.base && base.holder) held.push({ name: collection.name, label: collection.label, base: base.base, holder: base.holder.label });
409
+ if (!base.problem) continue;
410
+ const holders = entries.filter((e) => e.collection === collection.heldBy && e.holds === collection.name).map((e) => e.file);
411
+ for (const file of holders.length ? holders : [collection.folder]) report.errors.push({ file, path: holders.length ? 'holds' : '', message: base.problem });
412
+ }
413
+ // Decision 14: a route left at a held base serves those addresses ahead of the catch-all.
414
+ const leftBehind = leftBehindRoutes(routes, held);
415
+ for (const left of leftBehind) report.errors.push({ file: left.route.file, path: '', message: leftBehindMessage(left) });
416
+
380
417
  const urls = new Map<string, DocumentEntry>();
381
418
  for (const entry of entries) {
382
419
  const c: Context = { file: entry.file, site, report, warningContext, exists };
@@ -399,13 +436,17 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
399
436
  if (isObject(raw) && raw[SCHEMA_KEY] === undefined) warning(c, '', `This file has no "$schema" (${JSON.stringify(schemaRef(collection, entry.file))}). \`caracms validate --fix\` adds it.`);
400
437
  const saved = savedForm(text, collection, site);
401
438
  if (saved !== null && saved !== text) warning(c, '', REFORMAT_MESSAGE);
402
- if (entry.url !== null && collection.urlBase !== null) {
439
+ if (entry.url !== null) {
403
440
  const route = codeRouteAt(entry.url, codeRoutes);
404
- if (route) error(c, '', `${codeRouteMessage(entry.url, route)} Delete ${route.file} if this document replaces it, or move the document.`);
441
+ // A route left at a held base is reported once, on the route (above), not on every document under it.
442
+ if (route && !leftBehind.some((l) => l.route.file === route.file)) error(c, '', `${codeRouteMessage(entry.url, route)} Delete ${route.file} if this document replaces it, or move the document.`);
405
443
  const same = sameAddress(entry.url);
406
444
  const first = urls.get(same);
407
- if (first) error(c, '', `${first.file} is at ${entry.url} too. Two documents can't share an address: move one.`);
408
- else urls.set(same, entry);
445
+ if (first) {
446
+ // Decision 8: a clash is an error on both files (each names the other).
447
+ error(c, '', `${first.file} is at ${entry.url} too. Two documents can't share an address: move one.`);
448
+ report.errors.push({ file: first.file, path: '', message: `${entry.file} is at ${entry.url} too. Two documents can't share an address: move one.` });
449
+ } else urls.set(same, entry);
409
450
  }
410
451
  }
411
452
 
@@ -108,6 +108,32 @@ export function codeRoutesFrom(routes: readonly CodeRoute[], collections: readon
108
108
  return routes.filter((r) => shadowing.has(r) || !(r.dynamic && bases.has(sameAddress(r.url))));
109
109
  }
110
110
 
111
+ /**
112
+ * Collections under pages (Decision 14): the prefix collections whose base has a route with
113
+ * parameters of its own (`tags/[slug].astro`), which the catch-all's reader leaves to that route,
114
+ * so no address is built twice. Never a collection at `/` (its own route is the catch-all) or a
115
+ * held one (its documents are always the catch-all's).
116
+ */
117
+ export function ownRoutedCollections<C extends { name: string; urlBase: string | null; heldBy?: string | null }>(routes: readonly CodeRoute[], collections: readonly C[]): C[] {
118
+ return collections.filter((c) => c.urlBase !== null && !c.heldBy && sameAddress(c.urlBase) !== '/' && routes.some((r) => r.dynamic && sameAddress(r.url) === sameAddress(c.urlBase!)));
119
+ }
120
+
121
+ /** A route with parameters left at a held collection's resolved base (Decision 14): it serves those addresses ahead of the catch-all. */
122
+ export interface LeftBehindRoute {
123
+ route: CodeRoute;
124
+ /** The held collection's label, its resolved base, and its holder's label. */
125
+ collection: { name: string; label: string; base: string; holder: string };
126
+ }
127
+
128
+ /** Every route with parameters whose static prefix is a held collection's resolved base (`journal/[slug].astro` after the switch). */
129
+ export function leftBehindRoutes(routes: readonly CodeRoute[], held: readonly LeftBehindRoute['collection'][]): LeftBehindRoute[] {
130
+ return held.flatMap((collection) => routes.filter((r) => r.dynamic && sameAddress(r.url) === sameAddress(collection.base)).map((route) => ({ route, collection })));
131
+ }
132
+
133
+ /** validate's and the dev log's sentence for a route left at a held base (Decision 14). */
134
+ export const leftBehindMessage = (l: LeftBehindRoute): string =>
135
+ `${l.route.file} serves ${l.collection.base}…, where the ${l.collection.holder} page's ${l.collection.label} documents are now. Delete it: the catch-all serves them.`;
136
+
111
137
  /** validate's sentence for a route that shadows a collection's documents. */
112
138
  export const shadowingMessage = (s: ShadowingRoute<{ name: string; urlBase: string | null }>): string =>
113
139
  `${s.route.file} is a second route with parameters at the ${s.collection.name} collection's address (${s.collection.urlBase}), beside its own ${s.own.file}. Astro ranks it first, so it serves the addresses of that collection's documents and hides them. Move it under a folder of its own, or delete it if the documents replace it.`;
@@ -14,7 +14,7 @@
14
14
  import { join } from 'node:path';
15
15
  import { site } from 'virtual:caracms/config';
16
16
  import { build } from 'virtual:caracms/public';
17
- import { documentUrl } from '../content.ts';
17
+ import { documentAddress } from '../content.ts';
18
18
  import type { PageProps } from '../document.ts';
19
19
  import { googleTagSnippet, headOf, withoutHtml } from '../head.ts';
20
20
  import { readMediaConfig } from '../media.ts';
@@ -30,17 +30,17 @@ const listPage = !preview && Number.isInteger(Astro.props.listPage) && Astro.pro
30
30
  const settingsText = await renderSourceOf(Astro.locals).readText(join(site.root, SITE_SETTINGS_FILE));
31
31
  // A public page's path is its own address; a preview's is the document's: the page component's
32
32
  // `path` when it's passed on, else the GET's `?path=` (for the home page's title).
33
- const previewPath = () => {
33
+ const previewPath = async () => {
34
34
  if (Astro.props.path !== undefined) return Astro.props.path;
35
- const c = Object.hasOwn(site.collections, collection) ? site.collections[collection]! : null;
36
35
  const path = Astro.url.searchParams.get('path');
37
- return c && path ? documentUrl(c, path) : null;
36
+ // Collections under pages: a held document's address is its holder's + its slug.
37
+ return path ? documentAddress(site, collection, path, renderSourceOf(Astro.locals)) : null;
38
38
  };
39
39
  const head = headOf({
40
40
  document,
41
41
  settingsText,
42
42
  // A public page's own address, without a `build.format: 'file'` prerender's `.html` (head.ts).
43
- path: preview ? previewPath() : withoutHtml(Astro.url.pathname),
43
+ path: preview ? await previewPath() : withoutHtml(Astro.url.pathname),
44
44
  siteUrl: preview ? null : build.siteUrl,
45
45
  media: readMediaConfig(),
46
46
  tagAllowed: build.production && !preview,
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { Page } from 'virtual:caracms/page';
30
30
  import { site } from 'virtual:caracms/config';
31
- import { documentUrl } from '../content.ts';
31
+ import { documentAddress } from '../content.ts';
32
32
  import { own, type AnyDocument, type PageProps } from '../document.ts';
33
33
  import { documentPath } from '../head.ts';
34
34
  import { documentReader, renderFields, renderSourceOf, settingsRenderData } from '../read.ts';
@@ -52,10 +52,10 @@ const reader = documentReader(site, source);
52
52
  const data = fields && values ? await renderFields(fields, values, reader) : {};
53
53
  const settings = await settingsRenderData(site, source, reader);
54
54
  const previewOf = Astro.props.previewPath ?? Astro.url.searchParams.get('path');
55
- const ownCollection = own(site.collections, collection);
56
55
  const path = documentPath({
57
56
  preview,
58
- previewUrl: preview && ownCollection && previewOf ? documentUrl(ownCollection, previewOf) : null,
57
+ // Collections under pages: a held document's address is its holder's + its slug, read through the render's source.
58
+ previewUrl: preview && previewOf ? await documentAddress(site, collection, previewOf, source) : null,
59
59
  pathname: Astro.url.pathname,
60
60
  listPage,
61
61
  });
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * `/sitemap.xml` (M6 Decision 12), prerendered at build from `SITE_URL` (Astro's `site`).
3
- * Every document with a URL: Pages, nested ones included, and every collection with a
4
- * `urlBase`, as absolute URLs on the site's address. One file, no index (a site's
3
+ * Every document with a URL: Pages, nested ones included, every collection with a
4
+ * `urlBase`, and every held one at its holder's address (collections under pages), as
5
+ * absolute URLs on the site's address. One file, no index (a site's
5
6
  * sitemap is small). Injected only when the address is known, so a build without
6
7
  * `SITE_URL` has no sitemap.
7
8
  */
@@ -19,9 +20,9 @@ const xml = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&
19
20
  export async function sitemapUrls(siteUrl: string): Promise<string[]> {
20
21
  const urls: string[] = [];
21
22
  for (const collection of siteCollections(site)) {
22
- if (collection.urlBase === null) continue;
23
+ if (collection.urlBase === null && !collection.heldBy) continue;
23
24
  for (const entry of await listDocuments(site, collection.name)) {
24
- // M9 Decision 7: a hidden page (or one under it) isn't on the site.
25
+ // M9 Decision 7: a hidden page (or one under it, or held by it) isn't on the site.
25
26
  if (entry.warning || entry.url === null || entry.hidden) continue;
26
27
  urls.push(`${siteUrl}${entry.url.split('/').map((p) => encodeURIComponent(p)).join('/')}`);
27
28
  }
package/src/config.ts CHANGED
@@ -41,8 +41,17 @@ export interface SectionsCollectionConfig {
41
41
  kind: 'sections';
42
42
  /** The folder holding the collection's content files, relative to the site's root. */
43
43
  folder: string;
44
- /** The URL the collection's documents live under: `/` for Pages, `/journal/` for a journal. */
45
- urlBase: string;
44
+ /** The URL the collection's documents live under: `/` for Pages, `/tags/` for tags. Leave it out for a collection a page holds (`heldBy`). */
45
+ urlBase?: string;
46
+ /**
47
+ * Collections under pages (Decision 2): the documents' address follows a page instead of a
48
+ * prefix in code. Names the nested section builder (`'pages'`) one of whose pages holds this
49
+ * collection: that page's file says `"holds": "<this collection>"`, and the documents are at
50
+ * the page's address + `/` + their slug (`/journal/<slug>`), wherever the page moves. Not
51
+ * with `urlBase`, and the collection is flat. Until a page holds it, its documents have no
52
+ * addresses (`caracms validate` says why).
53
+ */
54
+ heldBy?: string;
46
55
  /**
47
56
  * Pages' rule (Decision 8): documents nest in folders and the file's path is the
48
57
  * URL; `index.json` at the top of the folder is `urlBase` itself, and an
@@ -105,6 +114,15 @@ export interface FieldsCollectionConfig<F extends Fields = Fields> {
105
114
  * in the page component, as a shared block's own preview is.
106
115
  */
107
116
  urlBase?: string;
117
+ /**
118
+ * Collections under pages (Decision 2): the documents' address follows a page instead of a
119
+ * prefix in code. Names the nested section builder (`'pages'`) one of whose pages holds this
120
+ * collection: that page's file says `"holds": "<this collection>"`, and the documents are at
121
+ * the page's address + `/` + their slug (`/journal/<slug>`), wherever the page moves. Not
122
+ * with `urlBase`, and the collection is flat. Until a page holds it, its documents have no
123
+ * addresses (`caracms validate` says why).
124
+ */
125
+ heldBy?: string;
108
126
  /** Every document's fields, in the form's order. `title` is every document's own, so it can't be one of them. */
109
127
  fields: F;
110
128
  /**
@@ -267,8 +285,14 @@ export interface ResolvedCollection {
267
285
  folder: string;
268
286
  /** Absolute. */
269
287
  dir: string;
270
- /** Always starts and ends with `/`. Null for the reserved collections, and a fields collection with no pages of its own (M6c G1). */
288
+ /**
289
+ * Always starts and ends with `/`. Null for the reserved collections, and a fields collection with no pages of its own (M6c G1).
290
+ * A held collection's (`heldBy`) is null here, in the config, and its holder's address + `/` once a listing resolves it
291
+ * (held.ts → withHeldBases); every caller that has a listing uses the resolved one.
292
+ */
271
293
  urlBase: string | null;
294
+ /** Collections under pages (Decision 2): the nested section builder whose page holds this collection; absent when none does. */
295
+ heldBy?: string;
272
296
  nested: boolean;
273
297
  /**
274
298
  * A fields collection's fields (labelled); a section builder's page fields (M6c G2,
@@ -347,6 +371,12 @@ export interface ResolvedSite {
347
371
  * integration at setup (and by the commands). Absent before then: nothing is reserved.
348
372
  */
349
373
  codeRoutes?: CodeRoute[];
374
+ /**
375
+ * Collections under pages (Decision 14): every route in `src/pages/`, the collections' own
376
+ * included, read with `codeRoutes`. The catch-all's reader (content.ts → addressedDocuments)
377
+ * leaves out a prefix collection with its own route, and logs a route left at a held base.
378
+ */
379
+ pageRoutes?: CodeRoute[];
350
380
  }
351
381
 
352
382
  export class CaracmsConfigError extends Error {
@@ -419,12 +449,20 @@ export function resolveSite(config: SiteConfig, root: string): ResolvedSite {
419
449
  if (c.kind !== 'sections' && c.kind !== 'fields') fail(`collection "${name}": kind must be 'sections' or 'fields' (got ${JSON.stringify((c as { kind?: unknown }).kind)}).`);
420
450
  if (!c.folder) fail(`collection "${name}": folder is required.`);
421
451
  const dir = claimFolder(name, c.folder);
452
+ // Collections under pages (Decision 2): a page holds it, so its address comes from the content, never a urlBase too.
453
+ const heldBy = (c as { heldBy?: unknown }).heldBy;
454
+ if (heldBy !== undefined) {
455
+ if (typeof heldBy !== 'string' || heldBy === '') fail(`collection "${name}": heldBy must name the collection whose page holds it, like 'pages'.`);
456
+ if (c.urlBase !== undefined) fail(`collection "${name}": it has both urlBase ('${c.urlBase}') and heldBy ('${heldBy}'). Its address comes from one: a prefix in code (urlBase) or the page that holds it (heldBy). Take one out.`);
457
+ if ((c as { nested?: unknown }).nested) fail(`collection "${name}": a collection a page holds is flat (its documents are at the page's address + their slug), so it can't be "nested".`);
458
+ }
422
459
  // M6c (G1): a list-of-fields collection may have no pages of its own (no urlBase).
423
- const pageless = c.kind === 'fields' && c.urlBase === undefined;
424
- if (!pageless && (typeof c.urlBase !== 'string' || !c.urlBase.startsWith('/'))) {
425
- fail(c.urlBase === undefined ? `collection "${name}": a section builder needs a urlBase, like '/' or '/journal/' (its documents are pages there).` : `collection "${name}": urlBase must start with "/".`);
460
+ const pageless = c.kind === 'fields' && c.urlBase === undefined && heldBy === undefined;
461
+ if (!pageless && heldBy === undefined && (typeof c.urlBase !== 'string' || !c.urlBase.startsWith('/'))) {
462
+ fail(c.urlBase === undefined ? `collection "${name}": a section builder needs a urlBase, like '/' or '/tags/', or heldBy: 'pages' when a page holds it (its documents are pages there).` : `collection "${name}": urlBase must start with "/".`);
426
463
  }
427
- const urlBase = pageless ? null : c.urlBase!.endsWith('/') ? c.urlBase! : `${c.urlBase}/`;
464
+ const urlBase = pageless || heldBy !== undefined ? null : c.urlBase!.endsWith('/') ? c.urlBase! : `${c.urlBase}/`;
465
+ const held = heldBy !== undefined ? { heldBy: heldBy as string } : {};
428
466
  const label = c.label ?? titleCase(name);
429
467
  if (c.kind === 'fields') {
430
468
  if ((c as { nested?: unknown }).nested) fail(`collection "${name}": a list-of-fields collection is flat (one level of slugs under its urlBase); "nested" is for section builders.`);
@@ -432,7 +470,7 @@ export function resolveSite(config: SiteConfig, root: string): ResolvedSite {
432
470
  mustExist(c.component, `collection "${name}": component`);
433
471
  if (!c.fields || typeof c.fields !== 'object') fail(`collection "${name}": fields is required.`);
434
472
  fieldsCollections.push([name, c]);
435
- collections[name] = { name, label, kind: 'fields', folder: c.folder, dir, urlBase, nested: false, fields: {}, component: abs(c.component), columns: null };
473
+ collections[name] = { name, label, kind: 'fields', folder: c.folder, dir, urlBase, ...held, nested: false, fields: {}, component: abs(c.component), columns: null };
436
474
  } else {
437
475
  if (c.columns !== undefined && c.nested) fail(`collection "${name}": columns are for the editor's table of a flat collection; a nested one (Pages) is a tree.`);
438
476
  if (c.sort !== undefined && c.nested) fail(`collection "${name}": sort is for the editor's table of a flat collection; a nested one (Pages) is a tree.`);
@@ -441,10 +479,16 @@ export function resolveSite(config: SiteConfig, root: string): ResolvedSite {
441
479
  if (!c.fields || typeof c.fields !== 'object' || Array.isArray(c.fields)) fail(`collection "${name}": fields must be an object of fields, like { side: select({ options: ['left', 'right'] }) }.`);
442
480
  pageFieldCollections.push([name, c.fields]);
443
481
  }
444
- collections[name] = { name, label, kind: 'sections', folder: c.folder, dir, urlBase, nested: c.nested ?? false, fields: null, component: null, columns: null };
482
+ collections[name] = { name, label, kind: 'sections', folder: c.folder, dir, urlBase, ...held, nested: c.nested ?? false, fields: null, component: null, columns: null };
445
483
  }
446
484
  }
447
485
  if (Object.keys(collections).length === 0) fail('declare at least one collection.');
486
+ // Decision 6: a holder is a page of a nested section builder (Pages), whose slugs are addresses.
487
+ for (const c of Object.values(collections)) {
488
+ if (c.heldBy === undefined) continue;
489
+ const by = Object.hasOwn(collections, c.heldBy) ? collections[c.heldBy]! : fail(`collection "${c.name}": heldBy names "${c.heldBy}", which isn't one of the site's collections (${Object.keys(collections).join(', ')}).`);
490
+ if (by.kind !== 'sections' || !by.nested || by.urlBase === null) fail(`collection "${c.name}": heldBy names "${c.heldBy}", which isn't a nested section builder (like Pages), so none of its documents can hold a collection.`);
491
+ }
448
492
  const ownCollections = { ...collections };
449
493
 
450
494
  const settingsFolder = SITE_SETTINGS_FILE.slice(0, SITE_SETTINGS_FILE.lastIndexOf('/'));
@@ -475,8 +519,10 @@ export function resolveSite(config: SiteConfig, root: string): ResolvedSite {
475
519
  validateField(field, `${where}: field "${key}"`, fieldCheck(where));
476
520
  }
477
521
  const columns = checkColumns(where, c.columns, c.fields, fail);
478
- if (c.urlBase === undefined && columns?.includes('address')) fail(`${where}: its documents have no pages of their own (no urlBase), so there's no 'address' column.`);
479
- const sort = checkSort(where, c.sort, columns ?? DEFAULT_COLUMNS.filter((k) => k !== 'address' || c.urlBase !== undefined), c.fields, fail);
522
+ // A held collection (heldBy) has addresses too, once a page holds it (Contracts → Config).
523
+ const addressed = c.urlBase !== undefined || c.heldBy !== undefined;
524
+ if (!addressed && columns?.includes('address')) fail(`${where}: its documents have no pages of their own (no urlBase), so there's no 'address' column.`);
525
+ const sort = checkSort(where, c.sort, columns ?? DEFAULT_COLUMNS.filter((k) => k !== 'address' || addressed), c.fields, fail);
480
526
  collections[name] = { ...collections[name]!, fields: withLabels(c.fields), columns, sort };
481
527
  }
482
528
  // M6c (G2): a section builder's page fields, beside `title`, `blocks` and `seo` in the file.