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