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.
- package/README.md +59 -3
- package/bin/caracms.mjs +30 -6
- package/bin/gate.mjs +157 -6
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +51 -10
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +5 -2
- package/src/content.ts +148 -8
- package/src/document.ts +8 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +61 -16
- package/src/editor/client/documents/dialogs.tsx +104 -15
- package/src/editor/client/editor.ts +22 -1
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +19 -1
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +24 -9
- package/src/editor/server/move.ts +266 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +31 -5
- package/src/held.ts +116 -0
- package/src/index.ts +4 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +122 -87
- package/src/live/editor.ts +22 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/redirect-middleware.ts +3 -0
- package/src/redirects.ts +32 -1
- package/src/relink.ts +40 -9
- package/src/serialize.ts +1 -1
- 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,
|
|
95
|
+
the file-link pattern, Tina's home file and `holders` (which page holds which collection;
|
|
96
|
+
the only source of holding, so an empty table holds nothing). **`addressFields` matters:** Tina's schema has
|
|
96
97
|
no marker for a link field (a custom component serialises to `ui: {}`), so only strings
|
|
97
98
|
named `href` are known to be addresses; list every other one. Everything below the table is generic and
|
|
98
99
|
driven by Tina's generated schema; never add per-block code. If a block needs something
|
|
@@ -116,7 +117,11 @@ node scripts/tina-to-caracms.mjs --from ../share-web-main --scaffold-config
|
|
|
116
117
|
- `--scaffold-config` writes a starting `caracms.config.ts` from Tina's schema, with
|
|
117
118
|
Tina's labels and help. **Once.** After that the config and the block components are
|
|
118
119
|
the site's own, hand-maintained, and a normal run never touches them (section 4 says
|
|
119
|
-
which options only you can add).
|
|
120
|
+
which options only you can add). A collection the adapt table's `holders` names
|
|
121
|
+
(`games: 'games'`) is **held by that page**: the scaffold writes `heldBy: 'pages'` and
|
|
122
|
+
no `urlBase`, and every run writes `"holds": "games"` on the page (section 6). A
|
|
123
|
+
collection not in the table keeps `urlBase`. The script prints a hint for each sections
|
|
124
|
+
collection that has a same-named page but isn't in the table, saying what to add.
|
|
120
125
|
- `--recipients <address>` replaces every form's recipients in Site Settings with one
|
|
121
126
|
address. **Always pass it for a branch environment**: the converted Site Settings name
|
|
122
127
|
the client, and a test submission would email them. The run at the switch leaves it out.
|
|
@@ -140,7 +145,8 @@ caracms's (Tina's escapes such as `A\&M` and `mailto\:` undone), an image and it
|
|
|
140
145
|
sibling → one media value, a Mux clip → a video value, `linkLabel` + `href` → one
|
|
141
146
|
`link()`, references → document paths, Site Settings (`formNotifications` →
|
|
142
147
|
`formRecipients`, `adminBranding` → `brand`, `adminAccess` → `editors`, `defaultSeo` →
|
|
143
|
-
`seo`), file links → caracms file addresses,
|
|
148
|
+
`seo`), file links → caracms file addresses, Tina's `hidden` → `"hidden": true`
|
|
149
|
+
(section 6), and the page that sits at a collection's base → `"holds": "<collection>"`
|
|
144
150
|
(section 6).
|
|
145
151
|
|
|
146
152
|
## 3. Tina's pieces and what replaces each
|
|
@@ -150,7 +156,7 @@ sibling → one media value, a Mux clip → a video value, `linkLabel` + `href`
|
|
|
150
156
|
| `tina/`, `tina/__generated__/`, Tina's dependencies | yes | `caracms.config.ts` |
|
|
151
157
|
| `public/admin/`, `public/admin-previews/` | yes | `/admin` (caracms's editor) |
|
|
152
158
|
| `server.mjs`, `server/` (Express: forms, downloads, the admin, previews) | yes | `caracms start`; forms → `/_caracms/form` (section 8); downloads → caracms file addresses `/files/<12 hex>/<name>` |
|
|
153
|
-
| `src/pages/[...slug].astro` and
|
|
159
|
+
| `src/pages/[...slug].astro` and the collection routes reading Tina (`blog/[slug].astro`) | yes | the one catch-all (section 6) |
|
|
154
160
|
| `src/lib/sections.ts` (the mappers: Tina document → component props) | yes, except page-wide logic | blocks draw from their own data; page-wide rules → the page component (section 5) |
|
|
155
161
|
| `Sections.astro` / `PageView` | yes | the page component and `<Blocks>` |
|
|
156
162
|
| `data-tina-field` on every element | yes | caracms's markers, added by `Block`, `Text`, `RichText`, `Media` in a preview only |
|
|
@@ -301,17 +307,53 @@ block, when the template put it there: section links jump to those ids.
|
|
|
301
307
|
|
|
302
308
|
## 6. Routes, hidden pages and the 404
|
|
303
309
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
+
A collection with pages gets its address from one of three places, and the port picks
|
|
311
|
+
one per collection:
|
|
312
|
+
|
|
313
|
+
| Source | In the config | In the content | A document's address | In the template |
|
|
314
|
+
|---|---|---|---|---|
|
|
315
|
+
| **Pageless** | no `urlBase` | none | none | a team, awards, logos: other pages list them |
|
|
316
|
+
| **A prefix in code** | `urlBase: '/tags/'` | none | `urlBase` + slug | `/tags/<slug>`, with no Tags page |
|
|
317
|
+
| **Held by a page** | `heldBy: 'pages'` | the page says `"holds": "posts"` | the page's address + `/` + slug | the Blog page and its posts: `/blog/<slug>` |
|
|
318
|
+
|
|
319
|
+
**Don't write a route per collection.** A template's Blog page **holds** its posts
|
|
320
|
+
collection: the posts are at the page's address, so they follow the page wherever it
|
|
321
|
+
moves. A prefix with no page (tags) keeps `urlBase`. `init` writes one catch-all
|
|
322
|
+
(`src/pages/[...path].astro`, once Tina's `[...slug].astro` is gone: it refuses while
|
|
323
|
+
another route catches the top of the site), and it serves pages, held documents and
|
|
324
|
+
prefix documents alike, through the reader from `caracms/content`:
|
|
310
325
|
|
|
311
326
|
```ts
|
|
312
|
-
|
|
327
|
+
import { addressedDocuments } from 'caracms/content';
|
|
328
|
+
|
|
329
|
+
export async function getStaticPaths() {
|
|
330
|
+
const documents = await addressedDocuments(site); // { collection, path, url }[]
|
|
331
|
+
return documents.map((d) => ({ params: { path: d.url === '/' ? undefined : d.url.slice(1) }, props: { collection: d.collection, path: d.path } }));
|
|
332
|
+
}
|
|
313
333
|
```
|
|
314
334
|
|
|
335
|
+
The reader already leaves out **`e.warning`** (a file that can't be opened) and
|
|
336
|
+
**`e.hidden`** (a hidden page, one under it, and the documents of a hidden holder). A
|
|
337
|
+
site that renders on demand asks `documentAt(site, pathname)` instead: the document at a
|
|
338
|
+
request path, or `null`.
|
|
339
|
+
|
|
340
|
+
**The script holds only what the adapt table says.** `holders: { games: 'games' }` (or
|
|
341
|
+
`posts: 'blog'` where the folder isn't named like the page) makes `/games/` the page's
|
|
342
|
+
address and the collection's; with no entry nothing is held, so a site ported before this
|
|
343
|
+
keeps its `urlBase`. For each sections collection that has a same-named page and no entry,
|
|
344
|
+
the script prints a hint with the line to add (then re-run with `--scaffold-config
|
|
345
|
+
--force`); a fields collection is never suggested. Home never
|
|
346
|
+
holds a collection, one page holds one collection, and a held collection is flat;
|
|
347
|
+
`validate` says when any of that is wrong. A `holds` added to a page by hand is
|
|
348
|
+
lost on the next run (`content/` is written whole), so the table is where it belongs.
|
|
349
|
+
|
|
350
|
+
**Delete the template's collection routes.** A route with parameters left at a held base
|
|
351
|
+
(`src/pages/blog/[slug].astro`) is a `validate` error: the catch-all serves those
|
|
352
|
+
documents. A route at a prefix collection's base (`src/pages/tags/[slug].astro`) is the
|
|
353
|
+
site's own for that collection, and the reader leaves the collection out, so keep it only
|
|
354
|
+
for a page the catch-all can't draw (a listing built from logic); otherwise delete it
|
|
355
|
+
and let the catch-all serve the prefix too.
|
|
356
|
+
|
|
315
357
|
**Hidden pages:** Tina's `hidden` becomes `"hidden": true` at the top of the document,
|
|
316
358
|
after its title. A hidden page isn't served (visitors get the site's own `404.astro`),
|
|
317
359
|
isn't in the sitemap, isn't offered by the link picker, and isn't in a block's
|
|
@@ -52,6 +52,7 @@ Inline, `**bold**`, `*italic*` and `[text](href)` carry over (Tina's `__bold__`
|
|
|
52
52
|
- **Block types** take the adapt table's `blockRenames`, and otherwise go from camelCase to kebab case (`textMedia` → `text-media`).
|
|
53
53
|
- `seo { metaTitle, metaDescription, ogImage, ogImageAlt }` → caracms's `seo { title, description, image }`.
|
|
54
54
|
- **Hidden:** a section builder's boolean `hidden` → `"hidden": true` at the top level of the document, beside `title` and `blocks` (absent means shown). The home page can't be hidden: it's left shown, with a warning.
|
|
55
|
+
- **Holds:** the page the adapt table's `holders` names for a collection (`games: 'games'`, `posts: 'blog'`; the table is the only source, with no default from names) gets `"holds": "<collection>"` after `title` and `hidden`, and the scaffolded config gives that collection `heldBy: 'pages'` in place of `urlBase`. The home page never holds one. A table entry whose page doesn't exist is a warning. For each sections collection with a same-named page that isn't in the table, the script prints a hint line saying what to add (a fields collection is never suggested).
|
|
55
56
|
- Every file starts with its `$schema` (`../../schemas/<collection>.schema.json`), as `caracms schemas` stamps it.
|
|
56
57
|
- A key in a Tina file that its schema no longer has is left out, with a warning.
|
|
57
58
|
|
|
@@ -86,3 +87,4 @@ At the top of the script, under "ADAPT HERE". Everything else is generic.
|
|
|
86
87
|
| `docTitleAlt` | bare images whose alt is the document's title (a client's logo) |
|
|
87
88
|
| `fileLinks` | the Tina site's own file route and the bucket key behind it |
|
|
88
89
|
| `homeFile` | Tina's home page file |
|
|
90
|
+
| `holders` | a collection key → the page file that holds it (`games: 'games'`, `posts: 'blog'`); the only source of holding, so a collection not listed keeps its `urlBase` |
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* Tina's labels and help, over `caracms init`'s starting config. It refuses any other
|
|
20
20
|
* existing config unless `--force`. Do it once: after that the config and the block
|
|
21
21
|
* components are the site's own, hand-maintained, and a normal run never touches them.
|
|
22
|
+
* A collection whose template has a page at its base (`pages/games.json` for a `games`
|
|
23
|
+
* collection) is held by that page only if the adapt table `SITE.holders` says so
|
|
24
|
+
* (`games: 'games'`): then the scaffold writes `heldBy: 'pages'` in place of `urlBase`,
|
|
25
|
+
* and every run writes `"holds": "games"` on the page. Not in the table: the collection
|
|
26
|
+
* keeps `urlBase: '/games/'` (a prefix in code), and the script only prints a hint line
|
|
27
|
+
* for each sections collection that has a same-named page, saying what to add.
|
|
22
28
|
*
|
|
23
29
|
* WHAT IT OWNS: `content/`, nothing else. Each run deletes `content/` and writes it again
|
|
24
30
|
* from Tina's files, so the same input gives byte-identical output, and a run on newer
|
|
@@ -54,6 +60,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
54
60
|
* @property {string[]} docTitleAlt
|
|
55
61
|
* @property {{ pattern: RegExp, sourceKey: (name: string) => string }} fileLinks
|
|
56
62
|
* @property {string} homeFile
|
|
63
|
+
* @property {Record<string, string>} holders
|
|
57
64
|
*/
|
|
58
65
|
|
|
59
66
|
/** @type {SiteTable} */
|
|
@@ -87,6 +94,17 @@ export const SITE = {
|
|
|
87
94
|
fileLinks: { pattern: /^\/files\/([^/?#]+)$/, sourceKey: (name) => `uploads/${name}` },
|
|
88
95
|
/** Tina's home page file (it becomes `index`). */
|
|
89
96
|
homeFile: 'home',
|
|
97
|
+
/**
|
|
98
|
+
* The collections held by a page (collection key → page file name, without `.json`):
|
|
99
|
+
* this table is the only source, with no default from names (`games: 'games'`,
|
|
100
|
+
* `posts: 'blog'`). The scaffold's `heldBy` and every run's `"holds"` both come from it, so
|
|
101
|
+
* a site ported without an entry is never given a `holds` its config doesn't expect.
|
|
102
|
+
* The script suggests candidates: a sections collection with a same-named page that isn't
|
|
103
|
+
* listed here is printed as a line saying what to add.
|
|
104
|
+
*/
|
|
105
|
+
holders: {
|
|
106
|
+
// posts: 'blog',
|
|
107
|
+
},
|
|
90
108
|
};
|
|
91
109
|
|
|
92
110
|
// ── The template's conventions (the same in every site made from it) ────────────────────
|
|
@@ -516,6 +534,7 @@ export function layout(value, shape, depth = 0) {
|
|
|
516
534
|
* or fields) with its compiled fields, the blocks by caracms key, and Site Settings.
|
|
517
535
|
*/
|
|
518
536
|
export function readSchema(schema, site = SITE) {
|
|
537
|
+
/** @type {Record<string, any>} */
|
|
519
538
|
const collections = {};
|
|
520
539
|
const blocks = new Map(); // key → { template, label, specs }
|
|
521
540
|
const warnings = [];
|
|
@@ -554,7 +573,9 @@ export function readSchema(schema, site = SITE) {
|
|
|
554
573
|
}
|
|
555
574
|
const settings = schema.collections.find((c) => c.name === SETTINGS);
|
|
556
575
|
const settingsSpecs = settings ? compileFields(settings.fields.filter((f) => !OWN_SETTINGS.has(f.name)), SETTINGS, site) : [];
|
|
557
|
-
|
|
576
|
+
/** @type {string[]} Hint lines `findHolders` fills in. */
|
|
577
|
+
const holderCandidates = [];
|
|
578
|
+
return { collections, blocks, settings, settingsSpecs, warnings, holderCandidates };
|
|
558
579
|
}
|
|
559
580
|
/** A Tina template's own description (`ui.description`, or `description`), or ''. */
|
|
560
581
|
const templateDescription = (t) => String(t.ui?.description ?? t.description ?? '').trim();
|
|
@@ -618,12 +639,53 @@ const seoOf = (raw, conv, where) => {
|
|
|
618
639
|
};
|
|
619
640
|
const SEO_SHAPE = { kind: 'object', keys: new Map([['image', INLINE]]) };
|
|
620
641
|
|
|
642
|
+
/**
|
|
643
|
+
* The collections held by a page (collections under pages). The source is the adapt table
|
|
644
|
+
* `SITE.holders` (collection key → page file name) and nothing else: a collection the table
|
|
645
|
+
* names, whose page exists in the pages folder, is held by that page. The scaffold writes
|
|
646
|
+
* `heldBy: 'pages'` (and no `urlBase`) for it, and the conversion writes `"holds": "<key>"`
|
|
647
|
+
* on the page, both from this one decision. A collection the table doesn't name is a prefix
|
|
648
|
+
* in code and keeps its `urlBase` (so a site ported before this change, with an empty table,
|
|
649
|
+
* comes out the same). Sets `holder` (the page's file name) on each collection it finds;
|
|
650
|
+
* Home never holds one. A table entry naming a page that doesn't exist is a warning in
|
|
651
|
+
* `read.warnings`. `read.holderCandidates` lists the *sections* collections that aren't in
|
|
652
|
+
* the table but have a same-named page, as hint lines (a fields collection is never
|
|
653
|
+
* suggested). Returns the page name → collection key map.
|
|
654
|
+
*/
|
|
655
|
+
export function findHolders(read, from, site = SITE) {
|
|
656
|
+
const holders = new Map();
|
|
657
|
+
read.holderCandidates.length = 0;
|
|
658
|
+
const pages = Object.values(read.collections).find((c) => c.key === 'pages' && c.sections);
|
|
659
|
+
if (!pages) return holders;
|
|
660
|
+
const names = new Set(jsonFiles(path.join(from, pages.tina.path)).map((f) => f.replace(/\.json$/, '')));
|
|
661
|
+
for (const c of Object.values(read.collections)) {
|
|
662
|
+
if (c === pages || c.key === 'pages') continue;
|
|
663
|
+
const page = site.holders?.[c.key];
|
|
664
|
+
if (page === undefined) {
|
|
665
|
+
if (c.sections && names.has(c.key) && c.key !== site.homeFile && c.key !== 'index') {
|
|
666
|
+
read.holderCandidates.push(`${c.key} has a page at ${pages.tina.path}/${c.key}.json: to have that page hold it, add ${c.key}: '${c.key}' to SITE.holders, then re-run with --scaffold-config --force`);
|
|
667
|
+
}
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (!names.has(page)) {
|
|
671
|
+
read.warnings.push(`SITE.holders: ${c.key} is held by the page "${page}", but there's no ${pages.tina.path}/${page}.json, so it isn't held`);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (page === site.homeFile || page === 'index' || holders.has(page)) continue;
|
|
675
|
+
c.holder = page;
|
|
676
|
+
holders.set(page, c.key);
|
|
677
|
+
}
|
|
678
|
+
return holders;
|
|
679
|
+
}
|
|
680
|
+
|
|
621
681
|
/**
|
|
622
682
|
* Convert every document. Returns `{ files: Map<relPath, text>, conv }`; nothing is written.
|
|
623
683
|
* `recipients`: the `--recipients` address, or undefined.
|
|
684
|
+
* @param {{ from: string, schema: any, slugify: any, site?: SiteTable, recipients?: string }} args
|
|
624
685
|
*/
|
|
625
686
|
export function convertAll({ from, schema, slugify, site = SITE, recipients }) {
|
|
626
687
|
const read = readSchema(schema, site);
|
|
688
|
+
const holders = findHolders(read, from, site);
|
|
627
689
|
const conv = converter({ collections: read.collections, slugify, site });
|
|
628
690
|
conv.warnings.push(...read.warnings);
|
|
629
691
|
const docs = []; // { rel, data, shape }
|
|
@@ -643,6 +705,8 @@ export function convertAll({ from, schema, slugify, site = SITE, recipients }) {
|
|
|
643
705
|
if (name === 'index') conv.warn(where, "the home page can't be hidden in caracms, so it's shown");
|
|
644
706
|
else data.hidden = true;
|
|
645
707
|
}
|
|
708
|
+
// The page that holds a collection says so, after its title and hidden (the editor's order).
|
|
709
|
+
if (c.key === 'pages' && holders.has(name)) data.holds = holders.get(name);
|
|
646
710
|
const own = Object.fromEntries(Object.entries(raw).filter(([k]) => ![c.titleField, c.blocksField, ...(c.hasSeo ? ['seo'] : []), ...(c.hasHidden ? ['hidden'] : [])].includes(k)));
|
|
647
711
|
Object.assign(data, conv.object(c.specs, own, where, doc));
|
|
648
712
|
if (c.sections) {
|
|
@@ -867,12 +931,13 @@ export function scaffoldConfig(read) {
|
|
|
867
931
|
const cols = Object.values(read.collections).map((c) => {
|
|
868
932
|
const parts = [`label: ${q(c.label)}`];
|
|
869
933
|
if (c.sections) {
|
|
870
|
-
|
|
934
|
+
// A page at the collection's base holds it (heldBy); with no page the prefix stays in code (urlBase).
|
|
935
|
+
parts.push(`kind: 'sections'`, `folder: ${q(`content/${c.key}`)}`, c.holder ? `heldBy: 'pages'` : `urlBase: ${q(c.key === 'pages' ? '/' : `/${c.key}/`)}`);
|
|
871
936
|
if (c.key === 'pages') parts.push('nested: true');
|
|
872
937
|
if (c.blockKeys.length !== allBlocks.length) parts.push(`blocks: ${q(c.blockKeys)}`);
|
|
873
938
|
if (c.specs.length) parts.push(`fields: ${fieldsConfig(c.specs, read, used, '\t\t\t')}`);
|
|
874
939
|
} else {
|
|
875
|
-
parts.push(`kind: 'fields'`, `folder: ${q(`content/${c.key}`)}`, `component: ${q(`src/components/caracms/${pascal(c.key)}.astro`)}`, `fields: ${fieldsConfig(c.specs, read, used, '\t\t\t')}`);
|
|
940
|
+
parts.push(`kind: 'fields'`, `folder: ${q(`content/${c.key}`)}`, ...(c.holder ? [`heldBy: 'pages'`] : []), `component: ${q(`src/components/caracms/${pascal(c.key)}.astro`)}`, `fields: ${fieldsConfig(c.specs, read, used, '\t\t\t')}`);
|
|
876
941
|
}
|
|
877
942
|
return `\t\t${/^[a-z][a-z0-9]*$/.test(c.key) ? c.key : q(c.key)}: {\n\t\t\t${parts.join(',\n\t\t\t')},\n\t\t},`;
|
|
878
943
|
});
|
|
@@ -993,6 +1058,9 @@ async function main() {
|
|
|
993
1058
|
for (const f of [...conv.files.values()].sort((a, b) => a.key.localeCompare(b.key))) console.log(` ${f.sourceKey} → ${f.key}`);
|
|
994
1059
|
}
|
|
995
1060
|
}
|
|
1061
|
+
const held = Object.values(read.collections).filter((c) => c.holder);
|
|
1062
|
+
if (held.length) console.log(`Held by a page: ${held.map((c) => `${c.key} (the ${c.holder} page says "holds": "${c.key}")`).join(', ')}.`);
|
|
1063
|
+
for (const line of read.holderCandidates ?? []) console.log(`Hint: ${line}.`);
|
|
996
1064
|
if (args.recipients) console.log(`Form recipients: every form's are ${args.recipients} (--recipients).`);
|
|
997
1065
|
if (conv.warnings.length) console.log(`Warnings:\n ${conv.warnings.join('\n ')}`);
|
|
998
1066
|
return 0;
|
package/src/cli/validate.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
439
|
+
if (entry.url !== null) {
|
|
403
440
|
const route = codeRouteAt(entry.url, codeRoutes);
|
|
404
|
-
|
|
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)
|
|
408
|
-
|
|
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
|
|
package/src/code-routes.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
4
|
-
* `urlBase`,
|
|
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) => ({ '&': '&', '<': '&
|
|
|
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, `/
|
|
45
|
-
urlBase
|
|
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
|
-
/**
|
|
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 '/
|
|
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
|
-
|
|
479
|
-
const
|
|
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.
|