caracms 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -3
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +51 -10
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +5 -2
- package/src/content.ts +148 -8
- package/src/document.ts +8 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +61 -16
- package/src/editor/client/documents/dialogs.tsx +104 -15
- package/src/editor/client/editor.ts +22 -1
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +19 -1
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +24 -9
- package/src/editor/server/move.ts +266 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +31 -5
- package/src/held.ts +116 -0
- package/src/index.ts +4 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +122 -87
- package/src/live/editor.ts +22 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/redirects.ts +32 -1
- package/src/relink.ts +40 -9
- package/src/serialize.ts +1 -1
- package/src/slug.ts +15 -4
package/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.
|
package/src/content-schema.ts
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
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':
|
package/src/content.ts
CHANGED
|
@@ -36,6 +36,9 @@ import { isBuiltInColumn } from './columns.ts';
|
|
|
36
36
|
import { fieldsCover, sectionsCover, type CardCover } from './cards.ts';
|
|
37
37
|
import { own, parseAnyDocument, type AnyDocument, type DocumentKind } from './document.ts';
|
|
38
38
|
import { parseSource } from './json-source.ts';
|
|
39
|
+
import { hasHeld, heldBases, type HeldBase } from './held.ts';
|
|
40
|
+
import { leftBehindMessage, leftBehindRoutes, ownRoutedCollections } from './code-routes.ts';
|
|
41
|
+
import { sameAddress } from './slug.ts';
|
|
39
42
|
|
|
40
43
|
// Posts and tags, Decision 14: a route's reader on the disk (read.ts).
|
|
41
44
|
export { contentReader } from './read.ts';
|
|
@@ -79,6 +82,16 @@ export interface DocumentEntry {
|
|
|
79
82
|
*/
|
|
80
83
|
hidden?: true;
|
|
81
84
|
hiddenBy?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Collections under pages (Decision 11): set when `hiddenBy` is a page of another collection,
|
|
87
|
+
* the page that holds this document's collection (`pages`). Absent: `hiddenBy` is in this one.
|
|
88
|
+
*/
|
|
89
|
+
hiddenByCollection?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Collections under pages (Decision 5): a page's `"holds"`, the collection it holds (held.ts).
|
|
92
|
+
* Read from a file that can't be opened too, as far as it can be, so a broken holder is named.
|
|
93
|
+
*/
|
|
94
|
+
holds?: string;
|
|
82
95
|
/** A section builder's shared-block entries, by shared block path, in block order, once each (Decision 8). Absent otherwise. */
|
|
83
96
|
uses?: string[];
|
|
84
97
|
/** A shared block: its block's type (`''` when it has none). Absent otherwise. */
|
|
@@ -220,13 +233,17 @@ export function resolveDocumentFile(site: Pick<ResolvedSite, 'collections'>, col
|
|
|
220
233
|
return file;
|
|
221
234
|
}
|
|
222
235
|
|
|
223
|
-
/**
|
|
236
|
+
/**
|
|
237
|
+
* Every document in one collection, in several (in the order given), or in all of them (in
|
|
238
|
+
* config order), with warnings, in the editor's order. A held collection's documents have
|
|
239
|
+
* their resolved addresses (held.ts), whichever collections are listed.
|
|
240
|
+
*/
|
|
224
241
|
export async function listDocuments(
|
|
225
242
|
site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>,
|
|
226
|
-
collectionName?: string,
|
|
243
|
+
collectionName?: string | readonly string[],
|
|
227
244
|
source: ContentSource = diskSource,
|
|
228
245
|
): Promise<DocumentEntry[]> {
|
|
229
|
-
const names = collectionName
|
|
246
|
+
const names = collectionName === undefined ? Object.keys(site.collections) : typeof collectionName === 'string' ? [collectionName] : [...collectionName];
|
|
230
247
|
const all: DocumentEntry[] = [];
|
|
231
248
|
for (const name of names) {
|
|
232
249
|
const collection = own(site.collections, name);
|
|
@@ -236,7 +253,20 @@ export async function listDocuments(
|
|
|
236
253
|
if (collection.nested) hideChildren(entries);
|
|
237
254
|
all.push(...(collection.nested ? flattenTree(pageTree(entries)) : entries.sort(byLabel)));
|
|
238
255
|
}
|
|
239
|
-
|
|
256
|
+
// Collections under pages (Decision 7): a held collection's addresses come from its holder's listing.
|
|
257
|
+
const held = names.map((n) => site.collections[n]!).filter((c) => c.heldBy);
|
|
258
|
+
if (held.length) {
|
|
259
|
+
const holding: DocumentEntry[] = [];
|
|
260
|
+
for (const by of new Set(held.map((c) => c.heldBy!))) {
|
|
261
|
+
holding.push(...(names.includes(by) ? all.filter((e) => e.collection === by) : await listDocuments(site, by, source)));
|
|
262
|
+
}
|
|
263
|
+
const bases = heldBases(site.collections, holding);
|
|
264
|
+
for (const entry of all) {
|
|
265
|
+
const base = own(bases, entry.collection);
|
|
266
|
+
if (base) placeHeld(entry, base);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (collectionName === undefined) {
|
|
240
270
|
// Decision 8's "used on": every document whose block list has an entry for the shared block.
|
|
241
271
|
for (const entry of all) {
|
|
242
272
|
if (entry.kind !== 'shared') continue;
|
|
@@ -246,6 +276,44 @@ export async function listDocuments(
|
|
|
246
276
|
return all;
|
|
247
277
|
}
|
|
248
278
|
|
|
279
|
+
/** A held document's address and hidden mark from its collection's resolved base (held.ts). */
|
|
280
|
+
function placeHeld(entry: DocumentEntry, held: HeldBase): void {
|
|
281
|
+
entry.url = held.base === null ? null : held.base + entry.path;
|
|
282
|
+
// Decision 11: a hidden holder hides its documents, as a hidden page hides the pages under it.
|
|
283
|
+
if (held.hidden && held.holder) {
|
|
284
|
+
entry.hidden = true;
|
|
285
|
+
entry.hiddenBy = held.holder.path;
|
|
286
|
+
entry.hiddenByCollection = held.holder.collection;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A held collection's resolved base, from its holder collection's listing through `source`. */
|
|
291
|
+
export async function heldBaseOf(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, collectionName: string, source: ContentSource = diskSource): Promise<HeldBase | null> {
|
|
292
|
+
const collection = own(site.collections, collectionName);
|
|
293
|
+
if (!collection?.heldBy) return null;
|
|
294
|
+
return heldBases({ [collection.name]: collection }, await listDocuments(site, collection.heldBy, source))[collection.name] ?? null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Every held collection's resolved base (held.ts), from the listings of the collections that hold them; `{}` on a site with none. */
|
|
298
|
+
export async function heldBasesOf(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, source: ContentSource = diskSource): Promise<Record<string, HeldBase>> {
|
|
299
|
+
if (!hasHeld(site.collections)) return {};
|
|
300
|
+
const by = [...new Set(Object.values(site.collections).flatMap((c) => (c.heldBy ? [c.heldBy] : [])))];
|
|
301
|
+
const holding = (await Promise.all(by.map((name) => listDocuments(site, name, source)))).flat();
|
|
302
|
+
return heldBases(site.collections, holding);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A document's public address as its listing gives it: `documentUrl` for a collection with a
|
|
307
|
+
* prefix, the holder's address + `/` + the path for a held one (null while it isn't held).
|
|
308
|
+
*/
|
|
309
|
+
export async function documentAddress(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, collectionName: string, path: string, source: ContentSource = diskSource): Promise<string | null> {
|
|
310
|
+
const collection = own(site.collections, collectionName);
|
|
311
|
+
if (!collection) return null;
|
|
312
|
+
if (!collection.heldBy) return documentUrl(collection, path);
|
|
313
|
+
const held = await heldBaseOf(site, collectionName, source);
|
|
314
|
+
return held?.base ? held.base + path : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
249
317
|
/** M9 Decision 7: a hidden page's children are hidden with it; `hiddenBy` names the topmost hidden page above. */
|
|
250
318
|
function hideChildren(entries: DocumentEntry[]): void {
|
|
251
319
|
const hidden = new Set(entries.filter((e) => e.hidden).map((e) => e.path));
|
|
@@ -279,6 +347,10 @@ export async function readDocument(
|
|
|
279
347
|
if (text === null) throw new DocumentError('not-found', `${collection.label}: no document "${path}".`);
|
|
280
348
|
const { entry, document } = inspect(site, collection, path, text);
|
|
281
349
|
if (entry.warning || !document) throw new DocumentError(entry.warning!.code, entry.warning!.message);
|
|
350
|
+
if (collection.heldBy) {
|
|
351
|
+
const held = await heldBaseOf(site, collection.name, source);
|
|
352
|
+
if (held) placeHeld(entry, held);
|
|
353
|
+
}
|
|
282
354
|
return { entry, text, document };
|
|
283
355
|
}
|
|
284
356
|
|
|
@@ -312,10 +384,12 @@ function inspect(
|
|
|
312
384
|
url: documentUrl(collection, path),
|
|
313
385
|
};
|
|
314
386
|
const fileLabel = titleCase(path.split('/').pop()!);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
387
|
+
// Decision 13: a broken holder is named, so its `"holds"` is read from the text as far as it can be.
|
|
388
|
+
const holdsIn = () => (collection.kind === 'sections' ? /"holds"\s*:\s*"([^"\\]*)"/.exec(text)?.[1] : undefined);
|
|
389
|
+
const warn = (code: WarningCode, message: string, title = '') => {
|
|
390
|
+
const holds = holdsIn();
|
|
391
|
+
return { entry: { ...base, title, label: title || fileLabel, warning: { code, message }, ...(holds !== undefined ? { holds } : {}) }, document: null };
|
|
392
|
+
};
|
|
319
393
|
|
|
320
394
|
if (collection.nested && path.endsWith('/index')) {
|
|
321
395
|
const parent = path.slice(0, -'/index'.length);
|
|
@@ -336,6 +410,7 @@ function inspect(
|
|
|
336
410
|
const entry: DocumentEntry = { ...base, title, label: title, warning: null };
|
|
337
411
|
// M9 Decision 7: the home page is never hidden.
|
|
338
412
|
if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = true;
|
|
413
|
+
if ((document.kind === undefined || document.kind === 'sections') && document.holds !== undefined) entry.holds = document.holds;
|
|
339
414
|
if (document.kind === undefined || document.kind === 'sections') {
|
|
340
415
|
entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
|
|
341
416
|
} else if (document.kind === 'shared') {
|
|
@@ -422,3 +497,68 @@ const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true })
|
|
|
422
497
|
function byLabel(a: { label: string; path: string }, b: { label: string; path: string }): number {
|
|
423
498
|
return collator.compare(a.label, b.label) || collator.compare(a.path, b.path);
|
|
424
499
|
}
|
|
500
|
+
|
|
501
|
+
// ── The catch-all's reader (collections under pages, Decision 14) ────────────────
|
|
502
|
+
|
|
503
|
+
/** A public page the site's catch-all serves: its collection, its path there, and its address. */
|
|
504
|
+
export interface AddressedDocument {
|
|
505
|
+
collection: string;
|
|
506
|
+
path: string;
|
|
507
|
+
/** `/`, `/about`, `/journal/a-day-on-the-ferry`: no trailing slash, as the listing gives it. */
|
|
508
|
+
url: string;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** What the reader reads: the site, and the routes in its `src/pages/` (the integration sets `pageRoutes`). */
|
|
512
|
+
type CatchAllSite = Pick<ResolvedSite, 'collections' | 'root' | 'blocks'> & Partial<Pick<ResolvedSite, 'pageRoutes'>>;
|
|
513
|
+
|
|
514
|
+
const logged = new Set<string>();
|
|
515
|
+
/** The build's and dev's log, once per sentence per process (Decision 13: logged, never a failed build). */
|
|
516
|
+
function logOnce(message: string): void {
|
|
517
|
+
if (logged.has(message)) return;
|
|
518
|
+
logged.add(message);
|
|
519
|
+
console.warn(`[caracms] ${message}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Every public page of every collection with an address, for the site's one catch-all route
|
|
524
|
+
* (`src/pages/[...path].astro`'s `getStaticPaths`): Pages, every held collection's documents at
|
|
525
|
+
* their holder's address, and every prefix collection's (`/tags/<slug>`), in config order then
|
|
526
|
+
* the editor's. Left out: a file that can't be opened, a hidden page (and the documents of a
|
|
527
|
+
* hidden holder), a held collection no page holds, and a prefix collection whose base has its
|
|
528
|
+
* own route (`src/pages/tags/[slug].astro`), which that route serves. A holder problem or a
|
|
529
|
+
* route left at a held base is logged once (`caracms validate` reports both as errors).
|
|
530
|
+
* `source`: where the files come from (the disk by default, as a build reads them).
|
|
531
|
+
*/
|
|
532
|
+
export async function addressedDocuments(site: CatchAllSite, source: ContentSource = diskSource): Promise<AddressedDocument[]> {
|
|
533
|
+
const collections = Object.values(site.collections);
|
|
534
|
+
const routes = site.pageRoutes ?? [];
|
|
535
|
+
const ownRouted = new Set(ownRoutedCollections(routes, collections).map((c) => c.name));
|
|
536
|
+
const names = collections.filter((c) => (c.urlBase !== null || c.heldBy) && !ownRouted.has(c.name)).map((c) => c.name);
|
|
537
|
+
const entries = await listDocuments(site, names, source);
|
|
538
|
+
if (hasHeld(site.collections)) {
|
|
539
|
+
const held: { name: string; label: string; base: string; holder: string }[] = [];
|
|
540
|
+
for (const base of Object.values(await heldBasesOf(site, source))) {
|
|
541
|
+
if (base.problem) logOnce(base.problem);
|
|
542
|
+
const c = site.collections[base.collection]!;
|
|
543
|
+
if (base.base && base.holder) held.push({ name: c.name, label: c.label, base: base.base, holder: base.holder.label });
|
|
544
|
+
}
|
|
545
|
+
for (const left of leftBehindRoutes(routes, held)) logOnce(leftBehindMessage(left));
|
|
546
|
+
}
|
|
547
|
+
return entries.filter((e): e is DocumentEntry & { url: string } => e.url !== null && !e.warning && !e.hidden).map(({ collection, path, url }) => ({ collection, path, url }));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The document a request's path is (`/journal/a-day-on-the-ferry`, with or without a trailing
|
|
552
|
+
* slash, URL-encoded or not), for a catch-all that renders on demand; null when no public page
|
|
553
|
+
* is there (`addressedDocuments` says which are).
|
|
554
|
+
*/
|
|
555
|
+
export async function documentAt(site: CatchAllSite, pathname: string, source: ContentSource = diskSource): Promise<{ collection: string; path: string } | null> {
|
|
556
|
+
let at: string;
|
|
557
|
+
try {
|
|
558
|
+
at = sameAddress(decodeURIComponent(pathname));
|
|
559
|
+
} catch {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
const found = (await addressedDocuments(site, source)).find((d) => sameAddress(d.url) === at);
|
|
563
|
+
return found ? { collection: found.collection, path: found.path } : null;
|
|
564
|
+
}
|
package/src/document.ts
CHANGED
|
@@ -67,6 +67,13 @@ export interface SiteDocument {
|
|
|
67
67
|
* the key out of the file. The home page (`index`) can't be hidden (content.ts reads it as shown).
|
|
68
68
|
*/
|
|
69
69
|
hidden?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Collections under pages (Decision 5): the collection this page holds (the file's
|
|
72
|
+
* top-level `"holds"`, after `title` and `hidden`): its documents are at this page's
|
|
73
|
+
* address + `/` + their slug (held.ts). Edited in the file only, never by the editor,
|
|
74
|
+
* whose saves keep it where it is.
|
|
75
|
+
*/
|
|
76
|
+
holds?: string;
|
|
70
77
|
/**
|
|
71
78
|
* M6c (G2): the page fields' parsed values, keyed by field, every field of the
|
|
72
79
|
* collection's `fields` present (the file's top-level keys after `title`). Present
|
|
@@ -225,6 +232,7 @@ export function parseDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>):
|
|
|
225
232
|
blocks: rawBlocks.map((b, index) => parseBlock(b, index, site)),
|
|
226
233
|
...seoOf(doc),
|
|
227
234
|
...(doc.hidden === true ? { hidden: true } : {}),
|
|
235
|
+
...(typeof doc.holds === 'string' ? { holds: doc.holds } : {}),
|
|
228
236
|
};
|
|
229
237
|
}
|
|
230
238
|
|