caracms 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +73 -3
  2. package/bin/templates/catch-all.astro.txt +10 -9
  3. package/package.json +1 -1
  4. package/skills/caracms-adopt/SKILL.md +21 -4
  5. package/skills/caracms-blocks/SKILL.md +36 -13
  6. package/skills/caracms-content/SKILL.md +27 -6
  7. package/skills/caracms-tina/SKILL.md +53 -11
  8. package/skills/caracms-tina/field-kinds.md +2 -0
  9. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  10. package/src/cli/validate.ts +103 -12
  11. package/src/code-routes.ts +26 -0
  12. package/src/components/Head.astro +5 -5
  13. package/src/components/RenderDocument.astro +3 -3
  14. package/src/components/sitemap.xml.ts +5 -4
  15. package/src/config.ts +57 -11
  16. package/src/content-schema.ts +6 -2
  17. package/src/content.ts +188 -27
  18. package/src/document.ts +13 -2
  19. package/src/editor/README.md +3 -0
  20. package/src/editor/addresses.ts +6 -1
  21. package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
  22. package/src/editor/client/arrange/model.ts +659 -0
  23. package/src/editor/client/arrange/store.ts +335 -0
  24. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  25. package/src/editor/client/documents/actions.ts +75 -19
  26. package/src/editor/client/documents/dialogs.tsx +137 -18
  27. package/src/editor/client/editor.ts +78 -6
  28. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  29. package/src/editor/client/nav/DocRow.tsx +8 -1
  30. package/src/editor/client/nav/tree.ts +26 -1
  31. package/src/editor/client/pane/Pane.tsx +3 -0
  32. package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
  33. package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
  34. package/src/editor/client/pane/parts.tsx +14 -1
  35. package/src/editor/client/shell/Stage.tsx +8 -4
  36. package/src/editor/routes/admin.astro +1 -1
  37. package/src/editor/routes/arrange.ts +17 -0
  38. package/src/editor/routes/move.ts +1 -1
  39. package/src/editor/server/admin-props.ts +24 -4
  40. package/src/editor/server/arrange.ts +245 -0
  41. package/src/editor/server/create.ts +13 -2
  42. package/src/editor/server/drafts-store.ts +55 -10
  43. package/src/editor/server/move.ts +300 -81
  44. package/src/editor/styles/editor.css +5 -0
  45. package/src/editor/styles/levels/arrange.css +152 -0
  46. package/src/editor/styles/levels/collections.css +2 -0
  47. package/src/editor/styles/levels/m5a-documents.css +15 -0
  48. package/src/editor/types.ts +83 -7
  49. package/src/held.ts +116 -0
  50. package/src/index.ts +6 -2
  51. package/src/live/README.md +3 -2
  52. package/src/live/auth/middleware.ts +11 -0
  53. package/src/live/autosaves.ts +88 -1
  54. package/src/live/cloud.ts +131 -0
  55. package/src/live/drafts-store.ts +170 -77
  56. package/src/live/editor.ts +26 -4
  57. package/src/live/git.ts +63 -12
  58. package/src/live/github.ts +69 -7
  59. package/src/live/mode.ts +42 -6
  60. package/src/live/runtime.ts +18 -2
  61. package/src/page-order.ts +253 -0
  62. package/src/redirects.ts +64 -1
  63. package/src/relink.ts +40 -9
  64. package/src/reserved.ts +3 -2
  65. package/src/serialize.ts +18 -1
  66. package/src/slug.ts +15 -4
@@ -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';
@@ -32,7 +33,9 @@ import { fieldWarnings, parseDay, richTextOptions, type Field, type Fields, type
32
33
  import { measureLockup } from '../measure.ts';
33
34
  import { isFileKey } from '../files.ts';
34
35
  import { readRichText } from '../rich-text.ts';
35
- import { savedForm } from '../serialize.ts';
36
+ import { applyKeyedEdits, savedForm } from '../serialize.ts';
37
+ import { isOrderedCollection, prunedOrder, unknownOrderEntries } from '../page-order.ts';
38
+ import { SITE_SETTINGS_FILE } from '../reserved.ts';
36
39
  import { SCHEMA_KEY, schemaRef } from '../schema-key.ts';
37
40
  import { sameAddress } from '../slug.ts';
38
41
  import { hiddenDocuments, hiddenPages } from '../links.ts';
@@ -308,9 +311,10 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
308
311
  if (raw.title !== undefined && typeof raw.title !== 'string') error(c, 'title', `Expected text (a string), found ${what(raw.title)}.`);
309
312
  switch (collection.kind) {
310
313
  case 'sections': {
311
- checkFields(c, own, raw, '', `a ${collection.label} page`, 0, [SCHEMA_KEY, 'title', 'hidden', 'blocks', 'seo']);
314
+ checkFields(c, own, raw, '', `a ${collection.label} page`, 0, [SCHEMA_KEY, 'title', 'hidden', 'holds', 'blocks', 'seo']);
312
315
  // M9 Decision 7.
313
316
  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)}.`);
317
+ if (raw.holds !== undefined) checkHolds(c, collection, raw.holds);
314
318
  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
319
  else if (!Array.isArray(raw.blocks)) error(c, 'blocks', `Expected a list of blocks, found ${what(raw.blocks)}.`);
316
320
  else raw.blocks.forEach((b, i) => checkBlock(c, b, at('blocks', i), collection));
@@ -318,16 +322,19 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
318
322
  return;
319
323
  }
320
324
  case 'fields':
321
- checkFields(c, own, raw, '', `a ${collection.label} document`, 0, [SCHEMA_KEY, 'title', 'seo']);
325
+ checkFields(c, own, raw, '', `a ${collection.label} document`, 0, [SCHEMA_KEY, 'title', 'seo', 'holds']);
326
+ if (raw.holds !== undefined) error(c, 'holds', HOLDS_ON_A_PAGE);
322
327
  checkSeo(c, raw.seo, 'seo');
323
328
  return;
324
329
  case 'shared':
325
- unknownKeys(c, raw, [SCHEMA_KEY, 'title', 'block'], '', 'a shared block');
330
+ unknownKeys(c, raw, [SCHEMA_KEY, 'title', 'block', 'holds'], '', 'a shared block');
331
+ if (raw.holds !== undefined) error(c, 'holds', HOLDS_ON_A_PAGE);
326
332
  if (!Object.hasOwn(raw, 'block')) error(c, 'block', 'This shared block has no "block".');
327
333
  else checkBlock(c, raw.block, 'block', null);
328
334
  return;
329
335
  case 'settings': {
330
- checkFields(c, own, raw, '', 'Site Settings', 0, [SCHEMA_KEY, 'title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects']);
336
+ checkFields(c, own, raw, '', 'Site Settings', 0, [SCHEMA_KEY, 'title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects', 'pageOrder', 'holds']);
337
+ if (raw.holds !== undefined) error(c, 'holds', HOLDS_ON_A_PAGE);
331
338
  if (raw.editors !== undefined && !strings(raw.editors)) error(c, 'editors', `Expected a list of email addresses, found ${what(raw.editors)}.`);
332
339
  if (raw.brand !== undefined) {
333
340
  if (!isObject(raw.brand)) error(c, 'brand', `Expected { "name", "accent", "logo", "icon" }, found ${what(raw.brand)}.`);
@@ -349,11 +356,31 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
349
356
  if (raw.redirects !== undefined && !(Array.isArray(raw.redirects) && raw.redirects.every((r) => isObject(r) && typeof r.from === 'string' && typeof r.to === 'string'))) {
350
357
  error(c, 'redirects', 'Expected a list of { "from": "/old", "to": "/new" }.');
351
358
  }
359
+ if (raw.pageOrder !== undefined && !(isObject(raw.pageOrder) && Object.values(raw.pageOrder).every(strings))) {
360
+ error(c, 'pageOrder', 'Expected { "<parent path>": ["slug", …] }.');
361
+ }
352
362
  return;
353
363
  }
354
364
  }
355
365
  }
356
366
 
367
+ /** Collections under pages (Decision 13): `"holds"` on anything but a page. */
368
+ 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.';
369
+
370
+ /**
371
+ * Decision 13: a page's `"holds"` names a collection whose `heldBy` is this page's collection.
372
+ * Home holding one, and two pages holding one, are the collection's problems (held.ts), reported on their files by validateSite.
373
+ */
374
+ function checkHolds(c: Context, collection: ResolvedCollection, holds: unknown) {
375
+ if (typeof holds !== 'string') return error(c, 'holds', `Expected a collection's key (text), like "journal", found ${what(holds)}.`);
376
+ const held = own(c.site.collections, holds);
377
+ if (!held || !held.heldBy) {
378
+ const heldOnes = Object.values(c.site.collections).filter((h) => h.heldBy === collection.name).map((h) => `"${h.name}"`);
379
+ 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}'.`);
380
+ }
381
+ 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.`);
382
+ }
383
+
357
384
  /** Every finding for the site's content. `codeRoutes`: the site's own (default: read from its src/pages). */
358
385
  export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[] = readCodeRoutes(site.root, srcDirOf(site.root), Object.values(site.collections))): Promise<Report> {
359
386
  const report: Report = { errors: [], warnings: [] };
@@ -373,10 +400,25 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
373
400
  }
374
401
 
375
402
  // 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))) {
403
+ const routes = pagesRoutes(site.root, srcDirOf(site.root));
404
+ for (const s of shadowingRoutes(routes, Object.values(site.collections))) {
377
405
  report.errors.push({ file: s.route.file, path: '', message: shadowingMessage(s) });
378
406
  }
379
407
 
408
+ // Collections under pages (Decision 13): a held collection with no addresses, its sentence on each file it names.
409
+ const bases = heldBases(site.collections, entries);
410
+ const held: { name: string; label: string; base: string; holder: string }[] = [];
411
+ for (const base of Object.values(bases)) {
412
+ const collection = site.collections[base.collection]!;
413
+ if (base.base && base.holder) held.push({ name: collection.name, label: collection.label, base: base.base, holder: base.holder.label });
414
+ if (!base.problem) continue;
415
+ const holders = entries.filter((e) => e.collection === collection.heldBy && e.holds === collection.name).map((e) => e.file);
416
+ for (const file of holders.length ? holders : [collection.folder]) report.errors.push({ file, path: holders.length ? 'holds' : '', message: base.problem });
417
+ }
418
+ // Decision 14: a route left at a held base serves those addresses ahead of the catch-all.
419
+ const leftBehind = leftBehindRoutes(routes, held);
420
+ for (const left of leftBehind) report.errors.push({ file: left.route.file, path: '', message: leftBehindMessage(left) });
421
+
380
422
  const urls = new Map<string, DocumentEntry>();
381
423
  for (const entry of entries) {
382
424
  const c: Context = { file: entry.file, site, report, warningContext, exists };
@@ -399,13 +441,30 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
399
441
  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
442
  const saved = savedForm(text, collection, site);
401
443
  if (saved !== null && saved !== text) warning(c, '', REFORMAT_MESSAGE);
402
- if (entry.url !== null && collection.urlBase !== null) {
444
+ if (entry.url !== null) {
403
445
  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.`);
446
+ // A route left at a held base is reported once, on the route (above), not on every document under it.
447
+ 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
448
  const same = sameAddress(entry.url);
406
449
  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);
450
+ if (first) {
451
+ // Decision 8: a clash is an error on both files (each names the other).
452
+ error(c, '', `${first.file} is at ${entry.url} too. Two documents can't share an address: move one.`);
453
+ report.errors.push({ file: first.file, path: '', message: `${entry.file} is at ${entry.url} too. Two documents can't share an address: move one.` });
454
+ } else urls.set(same, entry);
455
+ }
456
+ }
457
+
458
+ // Arrange pages (Decision 6): a saved order's entry that names no page or group (the reader ignores it).
459
+ const order = savedOrderOf(site);
460
+ if (order) {
461
+ for (const u of unknownOrderEntries(order, orderedPaths(site, entries))) {
462
+ const where = u.key === '' ? 'the top level' : `/${u.key}`;
463
+ report.warnings.push({
464
+ file: SITE_SETTINGS_FILE,
465
+ path: `pageOrder[${JSON.stringify(u.key)}]`,
466
+ message: u.slug === null ? `The Pages tree's order names ${where}, where there are no pages. \`caracms validate --fix\` prunes it.` : `The Pages tree's order names "${u.slug}" in ${where}, where there's no page or group by that name. \`caracms validate --fix\` prunes it.`,
467
+ });
409
468
  }
410
469
  }
411
470
 
@@ -419,6 +478,38 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
419
478
  return report;
420
479
  }
421
480
 
481
+ /** The Pages collection's paths (page-order.ts → isOrderedCollection). */
482
+ const orderedPaths = (site: Pick<ResolvedSite, 'collections'>, entries: readonly DocumentEntry[]) =>
483
+ entries.filter((e) => isOrderedCollection(site.collections[e.collection]!)).map((e) => e.path);
484
+
485
+ /** Site Settings' `pageOrder` as the file has it, or null (no file, no key, or it doesn't parse). */
486
+ function savedOrderOf(site: Pick<ResolvedSite, 'root'>): Record<string, string[]> | null {
487
+ try {
488
+ const raw = JSON.parse(readFileSync(join(site.root, SITE_SETTINGS_FILE), 'utf8')) as unknown;
489
+ const order = isObject(raw) ? raw.pageOrder : undefined;
490
+ if (!isObject(order) || !Object.values(order).every(strings)) return null;
491
+ return order as Record<string, string[]>;
492
+ } catch {
493
+ return null;
494
+ }
495
+ }
496
+
497
+ /** Arrange pages (Decision 6): `--fix` takes the saved order's entries that name nothing out of Site Settings. Returns how many went. */
498
+ export async function prunePageOrder(site: Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>): Promise<number> {
499
+ const order = savedOrderOf(site);
500
+ const settings = Object.values(site.collections).find((c) => c.kind === 'settings');
501
+ if (!order || !settings) return 0;
502
+ const { order: kept, pruned } = prunedOrder(order, orderedPaths(site, await listDocuments(site)));
503
+ if (pruned === 0) return 0;
504
+ const file = join(site.root, SITE_SETTINGS_FILE);
505
+ const text = readFileSync(file, 'utf8');
506
+ const raw = JSON.parse(text) as Record<string, unknown>;
507
+ writeFileSync(file, applyKeyedEdits(text, { title: typeof raw.title === 'string' ? raw.title : '', data: { pageOrder: kept } }, settings));
508
+ return pruned;
509
+ }
510
+
511
+ const pruneReport = (n: number) => (n ? `caracms validate --fix:\n pruned ${n} page-order ${n === 1 ? 'entry' : 'entries'} (${SITE_SETTINGS_FILE})\n` : '');
512
+
422
513
  const REFORMAT_MESSAGE = "This file isn't laid out the way the editor saves it (two-space JSON, a trailing newline), so the editor's next save would reformat it and mix the two. `caracms validate --fix` rewrites it the editor's way, changing no values.";
423
514
 
424
515
  /**
@@ -463,7 +554,7 @@ export function formatReport(report: Report): string {
463
554
  export async function command(root: string, options: { fix?: boolean; json?: boolean } = {}): Promise<{ code: number; out: string; err?: string }> {
464
555
  const site = await loadSite(root);
465
556
  let fixed = '';
466
- if (options.fix) fixed = schemasReport(await runSchemas(site)) + reformatReport(await reformatContent(site));
557
+ if (options.fix) fixed = schemasReport(await runSchemas(site)) + reformatReport(await reformatContent(site)) + pruneReport(await prunePageOrder(site));
467
558
  const report = await validateSite(site);
468
559
  const code = report.errors.length ? 1 : 0;
469
560
  if (options.json) return { code, out: `${JSON.stringify(report, null, 2)}\n`, err: options.fix ? fixed : undefined };
@@ -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.
@@ -126,13 +126,16 @@ function document(collection: ResolvedCollection, properties: Record<string, Sch
126
126
  }
127
127
 
128
128
  /** A collection's schema (`schemas/<collection>.schema.json`, or the reserved two's names). */
129
- export function collectionSchema(site: Pick<ResolvedSite, 'blocks'>, collection: ResolvedCollection): Schema {
129
+ export function collectionSchema(site: Pick<ResolvedSite, 'blocks'> & Partial<Pick<ResolvedSite, 'collections'>>, collection: ResolvedCollection): Schema {
130
130
  const own = Object.fromEntries(Object.entries(collection.fields ?? {}).map(([key, f]) => [key, fieldSchema(f)]));
131
131
  switch (collection.kind) {
132
132
  case 'sections':
133
133
  // M9 Decision 7: `hidden: true` keeps the page off the site; absent, it's shown.
134
134
  const hidden: Schema = { description: 'Hidden from the site: not served, not in the sitemap, still edited in /admin.', const: true };
135
- return document(collection, { hidden, ...own, blocks: { type: 'array', items: { anyOf: [{ $ref: '#/$defs/shared' }, anyBlock(site)] } }, seo }, ['title', 'blocks'], { shared: sharedEntry, ...blockDefs(site) });
135
+ // Collections under pages (Decision 5): a page of a collection some collection's heldBy names may hold one of them.
136
+ const holdable = Object.values(site.collections ?? {}).filter((c) => c.heldBy === collection.name).map((c) => c.name);
137
+ const holds: Schema | null = holdable.length ? { description: 'The collection this page holds: its documents are at this page\'s address + their slug.', enum: holdable } : null;
138
+ return document(collection, { hidden, ...(holds ? { holds } : {}), ...own, blocks: { type: 'array', items: { anyOf: [{ $ref: '#/$defs/shared' }, anyBlock(site)] } }, seo }, ['title', 'blocks'], { shared: sharedEntry, ...blockDefs(site) });
136
139
  case 'fields':
137
140
  return document(collection, { ...own, seo }, ['title']);
138
141
  case 'shared':
@@ -147,6 +150,7 @@ export function collectionSchema(site: Pick<ResolvedSite, 'blocks'>, collection:
147
150
  googleTag: string,
148
151
  formRecipients: { type: 'object', additionalProperties: strings },
149
152
  redirects: { type: 'array', items: closed({ from: string, to: string }, ['from', 'to']) },
153
+ pageOrder: { type: 'object', additionalProperties: strings, description: "The Pages tree's order in the editor: a parent's path (\"\" for the top level) → its children's slugs. Written by Arrange pages." },
150
154
  ...own,
151
155
  },
152
156
  ['title'],