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.
Files changed (52) hide show
  1. package/README.md +59 -3
  2. package/bin/templates/catch-all.astro.txt +10 -9
  3. package/package.json +1 -1
  4. package/skills/caracms-adopt/SKILL.md +21 -4
  5. package/skills/caracms-blocks/SKILL.md +36 -13
  6. package/skills/caracms-content/SKILL.md +27 -6
  7. package/skills/caracms-tina/SKILL.md +53 -11
  8. package/skills/caracms-tina/field-kinds.md +2 -0
  9. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  10. package/src/cli/validate.ts +51 -10
  11. package/src/code-routes.ts +26 -0
  12. package/src/components/Head.astro +5 -5
  13. package/src/components/RenderDocument.astro +3 -3
  14. package/src/components/sitemap.xml.ts +5 -4
  15. package/src/config.ts +57 -11
  16. package/src/content-schema.ts +5 -2
  17. package/src/content.ts +148 -8
  18. package/src/document.ts +8 -0
  19. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  20. package/src/editor/client/documents/actions.ts +61 -16
  21. package/src/editor/client/documents/dialogs.tsx +104 -15
  22. package/src/editor/client/editor.ts +22 -1
  23. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  24. package/src/editor/client/nav/DocRow.tsx +8 -1
  25. package/src/editor/client/nav/tree.ts +26 -1
  26. package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
  27. package/src/editor/client/pane/parts.tsx +14 -1
  28. package/src/editor/routes/move.ts +1 -1
  29. package/src/editor/server/admin-props.ts +19 -1
  30. package/src/editor/server/create.ts +13 -2
  31. package/src/editor/server/drafts-store.ts +24 -9
  32. package/src/editor/server/move.ts +266 -81
  33. package/src/editor/styles/editor.css +5 -0
  34. package/src/editor/styles/levels/collections.css +2 -0
  35. package/src/editor/styles/levels/m5a-documents.css +15 -0
  36. package/src/editor/types.ts +31 -5
  37. package/src/held.ts +116 -0
  38. package/src/index.ts +4 -2
  39. package/src/live/README.md +3 -2
  40. package/src/live/auth/middleware.ts +11 -0
  41. package/src/live/autosaves.ts +88 -1
  42. package/src/live/cloud.ts +131 -0
  43. package/src/live/drafts-store.ts +122 -87
  44. package/src/live/editor.ts +22 -4
  45. package/src/live/git.ts +63 -12
  46. package/src/live/github.ts +69 -7
  47. package/src/live/mode.ts +42 -6
  48. package/src/live/runtime.ts +18 -2
  49. package/src/redirects.ts +32 -1
  50. package/src/relink.ts +40 -9
  51. package/src/serialize.ts +1 -1
  52. package/src/slug.ts +15 -4
@@ -28,7 +28,7 @@ import { useEditor, useStore } from '../context.tsx';
28
28
  import type { Editor } from '../editor.ts';
29
29
  import { createStore, type Store } from '../state/store.ts';
30
30
  import { Icon } from '../shell/Icons.tsx';
31
- import { docIcon } from '../nav/DocRow.tsx';
31
+ import { docIcon, hiddenTitle } from '../nav/DocRow.tsx';
32
32
  import { DocMarksView } from '../live/marks.tsx';
33
33
  import { when } from '../live/format.ts';
34
34
  import { openDialog } from '../documents/dialogs.tsx';
@@ -378,6 +378,14 @@ function Row(props: { doc: ListedDocument; columns: readonly TableColumn[]; curr
378
378
  <span className="sr">, can't be opened: {doc.warning.message}</span>
379
379
  </span>
380
380
  ) : null}
381
+ {/* M9 Decision 7, collections under pages (Decision 11): hidden, or hidden with the page that holds it, named. */}
382
+ {/* The sidebar's mark: the word, small and grey; the page that hides it on hover and to a screen reader. */}
383
+ {doc.hidden ? (
384
+ <span className="hmark" data-mark="hidden" title={hiddenTitle(doc, editor.documents.get())}>
385
+ Hidden
386
+ {doc.hiddenBy ? <span className="sr">: {hiddenTitle(doc, editor.documents.get())}</span> : null}
387
+ </span>
388
+ ) : null}
381
389
  {editor.mode === 'live' ? <DocMarksView file={doc.file} /> : <span data-slot="presence" hidden />}
382
390
  </span>
383
391
  {props.extra ? <span className="ct-extra">{props.extra}</span> : null}
@@ -70,31 +70,76 @@ export async function deleteDocument(editor: Editor, ref: DocRef, title: string)
70
70
  /**
71
71
  * Change a document's address (pre-M7 item 8). Its unsent edits go first (the move
72
72
  * carries them), then the document opens at its new address in place of the old one,
73
- * and the list is read again (other documents' links changed too).
73
+ * and the list is read again (other documents' links changed too). `redirect` false: no
74
+ * redirect from the old address.
74
75
  */
75
- export async function moveDocument(editor: Editor, ref: DocRef, to: string, title: string): Promise<{ ok: true; body: MoveResponse } | Refused> {
76
- const id = docId(ref);
77
- const open = sameDoc(editor.doc.get().ref, ref);
76
+ export async function moveDocument(editor: Editor, ref: DocRef, to: string, title: string, redirect = true): Promise<{ ok: true; body: MoveResponse } | Refused> {
77
+ // Everything under it moves with it (collections under pages, Decision 9): each of those documents' unsent edits too.
78
+ const moving = addressesMoving(editor.site?.collections ?? [], ref, to, editor.documents?.get() ?? []).map((m) => ({ collection: m.doc.collection, path: m.doc.path }));
79
+ const refs = moving.some((m) => sameDoc(m, ref)) ? moving : [ref, ...moving];
78
80
  // Nothing moves while the keeper still holds edits the server hasn't got (a held conflict, a
79
81
  // signed-out session, a refused write): the move would discard them.
80
- let flushed = true;
81
- await editor.keeper?.flush(id).catch(() => {
82
- flushed = false;
83
- });
84
- if (!flushed || editor.keeper?.dirty(id)) {
85
- return { ok: false, status: 0, error: `${title} has changes that haven't been saved yet, so its address can't change. Sort those out first (Use mine or Discard mine, or sign in again), then try again.`, code: 'unsent', usedOn: null };
82
+ for (const r of refs) {
83
+ const id = docId(r);
84
+ let flushed = true;
85
+ await editor.keeper?.flush(id).catch(() => {
86
+ flushed = false;
87
+ });
88
+ if (!flushed || editor.keeper?.dirty(id)) {
89
+ const what = sameDoc(r, ref) ? title : (editor.documents?.get().find((d) => sameDoc(d, r))?.label ?? r.path);
90
+ return { ok: false, status: 0, error: `${what} has changes that haven't been saved yet, so ${sameDoc(r, ref) ? 'its' : `${title}'s`} address can't change. Sort those out first (Use mine or Discard mine, or sign in again), then try again.`, code: 'unsent', usedOn: null };
91
+ }
86
92
  }
87
- const answer = await post<MoveResponse>(editor, ENDPOINTS.move, { collection: ref.collection, path: ref.path, to });
93
+ const answer = await post<MoveResponse>(editor, ENDPOINTS.move, { collection: ref.collection, path: ref.path, to, ...(redirect ? {} : { redirect: false }) });
88
94
  if (!answer.ok) return answer;
89
- editor.keeper?.discard(id);
90
- await editor.refreshDocuments().catch(() => undefined);
91
- if (open) await editor.openDocument({ collection: answer.body.collection, path: answer.body.path }, { history: 'replace' });
92
95
  const { url, from } = answer.body;
93
- editor.toast(`Moved ${title} to ${url}.`, 'check');
94
- editor.announce(`Moved ${title} from ${from.url} to ${url}.`);
96
+ const moved = answer.body.moved ?? [];
97
+ for (const r of refs) editor.keeper?.discard(docId(r));
98
+ await editor.refreshDocuments().catch(() => undefined);
99
+ // The open document goes with it when it's under it.
100
+ const openRef = editor.doc.get().ref;
101
+ const opened = sameDoc(openRef, ref) ? { collection: answer.body.collection, from: ref.path, to: answer.body.path } : moved.find((m) => m.collection === openRef.collection && m.from === openRef.path);
102
+ if (opened && opened.from !== opened.to) await editor.openDocument({ collection: opened.collection, path: opened.to }, { history: 'replace' });
103
+ else if (opened) await editor.reloadOpen();
104
+ const more = Math.max(moved.length - 1, 0);
105
+ editor.toast(more ? `Moved ${title} to ${url}, with ${more} more under it.` : `Moved ${title} to ${url}.`, 'check');
106
+ editor.announce(more ? `Moved ${title} from ${from.url} to ${url}, and ${more} more addresses under it.` : `Moved ${title} from ${from.url} to ${url}.`);
95
107
  return answer;
96
108
  }
97
109
 
110
+ /** One address Change address… changes (collections under pages, Decision 9). */
111
+ export interface AddressChange {
112
+ doc: ListedDocument;
113
+ /** Its new address; null while the new path isn't one yet. */
114
+ toUrl: string | null;
115
+ /** A document of a collection a page in the move holds (its file stays; only its address changes). */
116
+ held: boolean;
117
+ }
118
+
119
+ /**
120
+ * Every address Change address… on `ref` changes once it's at `to` (Decision 9): the
121
+ * document, the pages under it at any depth, and the documents of each collection held by
122
+ * it or a page under it. move.ts → `movesUnder` and `planMoves` work out the same, so the
123
+ * dialog lists them before asking.
124
+ */
125
+ export function addressesMoving(collections: readonly EditorAnyCollection[], ref: DocRef, to: string, documents: readonly ListedDocument[]): AddressChange[] {
126
+ const collection = collections.find((c) => c.name === ref.collection);
127
+ const root = documents.find((d) => d.collection === ref.collection && d.path === ref.path);
128
+ if (!collection || !root) return [];
129
+ const newPath = (path: string) => (path === ref.path ? to : `${to}${path.slice(ref.path.length)}`);
130
+ const pages = [root, ...(collection.nested ? pagesUnder(collection.name, ref.path, documents) : [])];
131
+ const out: AddressChange[] = pages.map((d) => ({ doc: d, toUrl: addressFor(collection, newPath(d.path)), held: false }));
132
+ for (const held of collections) {
133
+ const holder = held.holder;
134
+ if (!held.heldBy || !holder || holder.collection !== ref.collection) continue;
135
+ if (holder.path !== ref.path && !holder.path.startsWith(`${ref.path}/`)) continue;
136
+ const holderUrl = addressFor(collection, newPath(holder.path));
137
+ const base = holderUrl === null ? null : `${holderUrl.replace(/\/+$/, '')}/`;
138
+ for (const d of documents) if (d.collection === held.name && d.url !== null && !d.warning) out.push({ doc: d, toUrl: base === null ? null : base + d.path, held: true });
139
+ }
140
+ return out;
141
+ }
142
+
98
143
  /**
99
144
  * M6c G4 (Decision 9): Replace… on a file. Every unsent edit in this tab goes first, and
100
145
  * nothing is replaced while any is still held (a conflict, a refusal, a signed-out
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
21
21
  import { INDEX_MESSAGE, NO_ADDRESS_MESSAGE, RESERVED_SLUG, addressProblem, slugify } from '../../../slug.ts';
22
+ import { documentsNoun, holdsMessage } from '../../../held.ts';
22
23
  import { useEditor, useStore } from '../context.tsx';
23
24
  import type { Editor } from '../editor.ts';
24
25
  import { blockIcon } from '../block-list/summary.ts';
@@ -27,7 +28,7 @@ import { Icon } from '../shell/Icons.tsx';
27
28
  import { createStore, type Store } from '../state/store.ts';
28
29
  import type { CreateDeleteRefusal, DocRef, ListedDocument } from '../../types.ts';
29
30
  import { INSIDE_CLOSE, InsidePicker } from './InsidePicker.tsx';
30
- import { addressFor, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
31
+ import { addressFor, addressesMoving, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
31
32
  import { filesLabel, inboxFor } from '../inbox/store.ts';
32
33
  import { ReplaceDialog } from '../media/ReplaceDialog.tsx';
33
34
 
@@ -297,7 +298,18 @@ function NewDocument(props: { collection: string; inside?: string; at?: string }
297
298
  /**
298
299
  * A document's new address: for nested Pages the group it sits in ("Inside", New page's
299
300
  * select, with New group…) and the address's last part, shown whole as it's typed, with
300
- * New page's rules checked before asking the server. Pages under it stay where they are.
301
+ * New page's rules checked before asking the server.
302
+ *
303
+ * **Everything under it moves with it** (collections under pages, Decision 9): the pages
304
+ * under it at any depth, and the documents of a collection it (or a page under it) holds.
305
+ * Then the dialog lists every address that changes ("Its 1 page moves with it",
306
+ * "Its 24 journal entries move with it", the collection's own word for its documents), warns about a route of the site's own code under
307
+ * the old address (it doesn't move), and has one tick for the lot. A page with nothing
308
+ * under it keeps the single-page dialog.
309
+ *
310
+ * "Leave a redirect from …" ("Leave redirects from the old addresses" for several), ticked
311
+ * to start with, says whether the old addresses redirect; it shows only when there's one to
312
+ * leave (a live address, not one an earlier move since the last Publish already redirects).
301
313
  */
302
314
  function MoveDocument({ docRef }: { docRef: DocRef }) {
303
315
  const editor = useEditor();
@@ -311,8 +323,9 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
311
323
  const [part, setPart] = useState(docRef.path.slice(slash + 1));
312
324
  const [error, setError] = useState<string | null>(null);
313
325
  const [busy, setBusy] = useState(false);
314
- // Every page but this one: a page can't go under itself.
315
- const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents).filter((o) => o.path !== docRef.path) : []), [collection, documents, docRef.path]);
326
+ const [redirect, setRedirect] = useState(true);
327
+ // Every page but this one and the pages under it: a page can't go under itself.
328
+ const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents).filter((o) => o.path !== docRef.path && !o.path.startsWith(`${docRef.path}/`)) : []), [collection, documents, docRef.path]);
316
329
  if (!doc || !collection || !doc.url) return null;
317
330
  const nested = collection.nested;
318
331
  const newGroup = nested && inside === NEW_GROUP;
@@ -323,8 +336,10 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
323
336
  const url = addressFor(collection, path);
324
337
  const base = addressFor(collection, folder ? `${folder}/` : '') ?? '/';
325
338
  const same = path === docRef.path;
326
- const others = documents.filter((d) => !(d.collection === docRef.collection && d.path === docRef.path));
327
- // New page's rules, before asking the server (it applies the same).
339
+ const moving = addressesMoving(editor.site.collections, docRef, path, documents);
340
+ const many = moving.length > 1;
341
+ const others = documents.filter((d) => !moving.some((m) => m.doc === d));
342
+ // New page's rules, before asking the server (it applies the same, and checks every address that changes).
328
343
  const local = (() => {
329
344
  if (same) return null;
330
345
  if (newGroup && !slugify(group)) return group.trim() ? NO_ADDRESS_MESSAGE : null;
@@ -334,21 +349,38 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
334
349
  })();
335
350
  const shown = error ?? local;
336
351
  const blocked = same || !last || (newGroup && !slugify(group)) || !!local || busy;
337
- // Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from.
338
- const pending = (waiting ?? []).find((w) => w.collection === docRef.collection && w.path === docRef.path && w.change === 'new');
352
+ // Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from,
353
+ // and its redirect from there is there already, following this move.
354
+ const pendingOf = (d: ListedDocument) => (waiting ?? []).find((w) => w.collection === d.collection && w.path === d.path && (w.change === 'new' || !!w.movedFrom));
355
+ const pending = pendingOf(doc);
339
356
  const liveAddress = pending ? (pending.movedFrom ?? null) : doc.url;
340
- const kids = nested ? pagesUnder(collection.name, docRef.path, documents) : [];
357
+ const choose = many ? moving.some((m) => !pendingOf(m.doc)) : !pending;
341
358
  const host = editor.site.host;
342
359
  const confirm = async () => {
343
360
  if (blocked) return;
344
361
  setBusy(true);
345
362
  setError(null);
346
- const answer = await moveDocument(editor, docRef, path, doc.label);
363
+ const answer = await moveDocument(editor, docRef, path, doc.label, !choose || redirect);
347
364
  setBusy(false);
348
365
  if (answer.ok) closeDialog(editor);
349
366
  else setError(answer.error);
350
367
  };
351
- const intro = liveAddress ? `${liveAddress} will redirect here.${editor.mode === 'live' ? ' Goes live at the next Publish.' : ''}` : null;
368
+ const publish = editor.mode === 'live' ? ' Goes live at the next Publish.' : '';
369
+ const intro = !many && liveAddress && !choose ? `${liveAddress} will redirect here.${publish}` : null;
370
+ // What moves with it: the pages under it, then each held collection's documents.
371
+ const under = moving.filter((m) => !m.held && m.doc !== doc);
372
+ const heldBy = new Map<string, number>();
373
+ for (const m of moving) if (m.held) heldBy.set(m.doc.collection, (heldBy.get(m.doc.collection) ?? 0) + 1);
374
+ const labelOf = (name: string) => editor.site.collections.find((c) => c.name === name)?.label ?? name;
375
+ // "Its 1 page moves with it." "Its 24 journal entries move with it." "Its 2 pages and 24 journal entries move with it."
376
+ const heldParts = [...heldBy].map(([name, n]) => `${n} ${n === 1 ? singular({ kind: 'sections', nested: false, label: labelOf(name) }) : documentsNoun(labelOf(name))}`);
377
+ const parts = [...(under.length ? [`${under.length} page${under.length === 1 ? '' : 's'}`] : []), ...heldParts];
378
+ if (parts.length) parts[0] = `Its ${parts[0]}`;
379
+ const one = moving.length === 2 && parts.length === 1;
380
+ const withIt = `${andList(parts)} move${one ? 's' : ''} with it.`;
381
+ // A route of the site's own code under the old address stays where it is (Decision 10).
382
+ const oldBase = `${doc.url.replace(/\/+$/, '')}/`;
383
+ const routes = many ? (editor.site.codeRoutes ?? []).filter((r) => r.url.toLowerCase().startsWith(oldBase.toLowerCase())) : [];
352
384
  return (
353
385
  <Frame label="move">
354
386
  <form
@@ -408,10 +440,49 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
408
440
  </p>
409
441
  )}
410
442
  </div>
411
- {kids.length ? (
412
- <p className="field-help" data-kids={kids.length}>
413
- {andList(kids.map((k) => k.label))} stay{kids.length === 1 ? 's' : ''} where {kids.length === 1 ? 'it is' : 'they are'}, at {doc.url}/…
414
- </p>
443
+ {many ? (
444
+ <div className="field" data-moving={moving.length}>
445
+ <p className="field-help">
446
+ {withIt} {same ? 'The addresses now:' : 'The new addresses:'}
447
+ </p>
448
+ <ul className="mv-list" aria-label="Every address that changes" tabIndex={0}>
449
+ {moving.map((m) => (
450
+ <li key={`${m.doc.collection}/${m.doc.path}`} className="mono" data-to={m.toUrl ?? ''}>
451
+ {same ? m.doc.url : (m.toUrl ?? '…')}
452
+ </li>
453
+ ))}
454
+ </ul>
455
+ {routes.length ? (
456
+ <p className="field-help" data-routes={routes.length}>
457
+ <Icon name="warn" size="xs" /> {andList(routes.map((r) => r.file))} {routes.length === 1 ? 'is' : 'are'} the site’s own code under {doc.url}, so {routes.length === 1 ? 'it stays' : 'they stay'} where {routes.length === 1 ? 'it is' : 'they are'}.
458
+ </p>
459
+ ) : null}
460
+ </div>
461
+ ) : null}
462
+ {choose ? (
463
+ <div className="field">
464
+ <label className="check">
465
+ <input type="checkbox" id="mv-redirect" checked={redirect} aria-describedby="mv-redirect-h" onChange={(e) => setRedirect(e.target.checked)} />
466
+ <span>
467
+ {many ? (
468
+ 'Leave redirects from the old addresses'
469
+ ) : (
470
+ <>
471
+ Leave a redirect from <span className="mono">{doc.url}</span>
472
+ </>
473
+ )}
474
+ </span>
475
+ </label>
476
+ <p className="field-help" id="mv-redirect-h">
477
+ {many
478
+ ? redirect
479
+ ? `Anyone with an old address lands on the new one.${publish}`
480
+ : 'Nothing will be at the old addresses: links to them from elsewhere will find no page.'
481
+ : redirect
482
+ ? `Anyone with the old address lands here.${publish}`
483
+ : `Nothing will be at ${doc.url}: links to it from elsewhere will find no page.`}
484
+ </p>
485
+ </div>
415
486
  ) : null}
416
487
  <div className="acts">
417
488
  <button type="button" className="btn" onClick={() => closeDialog(editor)}>
@@ -569,6 +640,24 @@ function DeleteDocument({ docRef }: { docRef: DocRef }) {
569
640
  const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
570
641
  const collection = editor.site.collections.find((c) => c.name === docRef.collection);
571
642
  if (!doc || !collection) return null;
643
+ // Collections under pages (Decision 12): a page that holds a collection isn't deleted, and says so instead of asking.
644
+ const held = doc.holds ? editor.site.collections.find((c) => c.name === doc.holds && c.heldBy === collection.name) : undefined;
645
+ if (held) {
646
+ return (
647
+ <Frame alert label="holds" describedBy="dlg-d">
648
+ <div className="icon-h" style={{ background: 'var(--status-warning-wash)', color: 'var(--status-warning)' }}>
649
+ <Icon name="warn" />
650
+ </div>
651
+ <h2 id="dlg-h">{doc.label} can’t be deleted</h2>
652
+ <p id="dlg-d">{holdsMessage(doc.label, held.label)}</p>
653
+ <div className="acts">
654
+ <button type="button" className="btn ink" data-act="holds-ok" onClick={() => closeDialog(editor)}>
655
+ OK
656
+ </button>
657
+ </div>
658
+ </Frame>
659
+ );
660
+ }
572
661
  const shared = collection.kind === 'shared';
573
662
  const kids = collection.nested ? pagesUnder(collection.name, doc.path, documents) : [];
574
663
  const host = editor.site.host;
@@ -31,6 +31,7 @@
31
31
  * dismissNotice(id) / signOut() / signInAgain()
32
32
  */
33
33
  import { addressOf, inboxAddress, inboxFromAddress, refFromAddress } from '../addresses.ts';
34
+ import { heldBases } from '../../held.ts';
34
35
  import {
35
36
  docId,
36
37
  ENDPOINTS,
@@ -298,6 +299,24 @@ export interface Editor {
298
299
  timeZone(): string;
299
300
  }
300
301
 
302
+ /**
303
+ * Collections under pages (Decision 7): each held collection's base, worked out again from a
304
+ * fresh listing (held.ts), so a holder that moved takes its collection's addresses with it in
305
+ * every dialog and table without a reload. The payload's own objects are updated in place.
306
+ */
307
+ export function followHolders(site: Pick<EditorSite, 'collections' | 'allCollections'>, listed: readonly ListedDocument[]): void {
308
+ if (!site.collections.some((c) => c.heldBy)) return;
309
+ const bases = heldBases(Object.fromEntries(site.collections.map((c) => [c.name, c])), listed);
310
+ for (const b of Object.values(bases)) {
311
+ for (const c of [...site.collections, ...(site.allCollections ?? [])]) {
312
+ if (c.name !== b.collection) continue;
313
+ c.urlBase = b.base;
314
+ c.holder = b.holder ? { collection: b.holder.collection, path: b.holder.path } : null;
315
+ c.problem = b.problem;
316
+ }
317
+ }
318
+ }
319
+
301
320
  export function createEditor(props: AdminProps, options: { client: boolean }): Editor {
302
321
  const { site } = props;
303
322
  const opened = props.opened;
@@ -743,7 +762,9 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
743
762
  async refreshDocuments() {
744
763
  const response = await fetch(ENDPOINTS.documents, { headers: { Accept: 'application/json' } });
745
764
  if (!response.ok) return;
746
- documents.set((await response.json()) as ListedDocument[]);
765
+ const listed = (await response.json()) as ListedDocument[];
766
+ followHolders(site, listed);
767
+ documents.set(listed);
747
768
  // M6 Decision 9: the unread count rides along (absent when it couldn't be read).
748
769
  const unread = response.headers.get(UNREAD_HEADER);
749
770
  if (unread !== null && /^\d+$/.test(unread)) submissions.set({ unread: Number(unread) });
@@ -7,11 +7,16 @@
7
7
  * which can't be hidden.
8
8
  * - A page under a hidden page is hidden with it (content.ts → `hiddenBy`): the switch shows
9
9
  * on and can't be turned off here, and its help names the page to show again.
10
+ * - Collections under pages (Decision 5): a page that holds a collection says so beside it,
11
+ * "Holds Journal (24)" (every document, hidden ones included). Nothing here changes it:
12
+ * the `holds` line is the file's, a developer's call.
10
13
  */
11
14
  import { useMemo } from 'react';
12
15
  import { useEditor, useStore } from '../context.tsx';
13
16
  import { FieldFrame } from './FieldFrame.tsx';
14
17
  import { FieldHostProvider, type FieldHost } from './host.tsx';
18
+ import { docIcon } from '../nav/DocRow.tsx';
19
+ import { Icon } from '../shell/Icons.tsx';
15
20
 
16
21
  const SCOPE = 'page-hidden';
17
22
  const NAME = 'hidden';
@@ -30,11 +35,14 @@ export function HiddenToggle() {
30
35
  const collection = editor.site.collections.find((c) => c.name === ref.collection);
31
36
  if (kind !== 'sections' || !collection?.urlBase || ref.path === 'index') return null;
32
37
  const listed = documents.find((d) => d.collection === ref.collection && d.path === ref.path);
33
- const by = listed?.hiddenBy ? documents.find((d) => d.collection === ref.collection && d.path === listed.hiddenBy) : undefined;
38
+ // Collections under pages (Decision 11): a held document is hidden with the page that holds its collection.
39
+ const byCollection = listed?.hiddenByCollection ?? ref.collection;
40
+ const by = listed?.hiddenBy ? documents.find((d) => d.collection === byCollection && d.path === listed.hiddenBy) : undefined;
34
41
  const on = hidden || !!by;
42
+ const holds = listed?.holds ? editor.site.collections.find((c) => c.name === listed.holds && c.heldBy === ref.collection) : undefined;
35
43
  const id = `f-${SCOPE}-${NAME}`;
36
44
  const help = by
37
- ? `Hidden with ${by.label}, the page it's under. Show that page again to show this one.`
45
+ ? `Hidden with ${by.label}, the page ${listed?.hiddenByCollection ? `that holds ${collection.label}` : "it's under"}. Show that page again to show this one.`
38
46
  : 'Visitors get the 404 page, and it’s left out of the sitemap and the link picker. You can still edit, preview and publish it.';
39
47
  return (
40
48
  <div className="page-hidden" data-page-hidden={on ? 'on' : 'off'}>
@@ -46,6 +54,14 @@ export function HiddenToggle() {
46
54
  </button>
47
55
  </FieldFrame>
48
56
  </FieldHostProvider>
57
+ {holds ? (
58
+ <p className="page-holds" data-holds={holds.name}>
59
+ <Icon name={docIcon(holds)} size="xs" />
60
+ <span>
61
+ Holds {holds.label} ({documents.filter((d) => d.collection === holds.name).length})
62
+ </span>
63
+ </p>
64
+ ) : null}
49
65
  </div>
50
66
  );
51
67
  }
@@ -26,6 +26,13 @@ export function docIcon(collection: { nested: boolean; kind?: string } | undefin
26
26
  return 'journal';
27
27
  }
28
28
 
29
+ /** A hidden document's mark's words: "Journal is hidden" for one hidden with the page that holds its collection (or the page it's under). */
30
+ export function hiddenTitle(doc: ListedDocument, documents: readonly ListedDocument[]): string {
31
+ if (!doc.hiddenBy) return 'Hidden from the site';
32
+ const by = documents.find((d) => d.collection === (doc.hiddenByCollection ?? doc.collection) && d.path === doc.hiddenBy);
33
+ return by ? `${by.label} is hidden` : 'Hidden with the page it’s under';
34
+ }
35
+
29
36
  export function DocRow(props: {
30
37
  doc: ListedDocument;
31
38
  crumb?: string;
@@ -84,7 +91,7 @@ export function DocRow(props: {
84
91
  ) : null}
85
92
  {/* M9 Decision 7: hidden from the site (or under a hidden page). */}
86
93
  {doc.hidden ? (
87
- <span className="hmark" data-mark="hidden" title={doc.hiddenBy ? 'Hidden from the site with the page it’s under' : 'Hidden from the site'}>
94
+ <span className="hmark" data-mark="hidden" title={hiddenTitle(doc, editor.documents.get())}>
88
95
  Hidden
89
96
  </span>
90
97
  ) : null}
@@ -29,6 +29,22 @@ export type TreeRow =
29
29
  /** Documents under it (any depth), for a bubbled mark when it's closed. */
30
30
  under: ListedDocument[];
31
31
  }
32
+ | {
33
+ /**
34
+ * Collections under pages (Decision 4): a collection the page above holds, as one row
35
+ * (its name and count, opening its table), the first row under its holder whenever the
36
+ * holder's row shows. Its documents never go in the tree.
37
+ */
38
+ kind: 'held';
39
+ /** The holder's path (the row's position). */
40
+ path: string;
41
+ depth: number;
42
+ collection: string;
43
+ label: string;
44
+ /** Every document in it, hidden ones included (as its sidebar entry counts them). */
45
+ count: number;
46
+ files: string[];
47
+ }
32
48
  | {
33
49
  /** A folder with no page of its own: shows or hides its children only. */
34
50
  kind: 'group';
@@ -46,7 +62,13 @@ export type TreeRow =
46
62
  * closed group (at any depth) isn't returned. `documents` must be one collection's,
47
63
  * in the reader's order.
48
64
  */
49
- export function treeRows(collection: string, documents: readonly ListedDocument[], open: UiState['open']): TreeRow[] {
65
+ export function treeRows(
66
+ collection: string,
67
+ documents: readonly ListedDocument[],
68
+ open: UiState['open'],
69
+ /** The collections pages of this one hold, each with its holder's path and its documents' files. */
70
+ held: readonly { collection: string; label: string; holder: string; files: string[] }[] = [],
71
+ ): TreeRow[] {
50
72
  const paths = new Set(documents.map((d) => d.path));
51
73
  const isOpen = (path: string) => open[groupKey(collection, path)] === true;
52
74
  const under = (path: string) => documents.filter((d) => d.path.startsWith(`${path}/`));
@@ -88,6 +110,9 @@ export function treeRows(collection: string, documents: readonly ListedDocument[
88
110
  key: groupKey(collection, doc.path),
89
111
  under: kids,
90
112
  });
113
+ for (const h of held) {
114
+ if (h.holder === doc.path) rows.push({ kind: 'held', path: doc.path, depth: segments.length, collection: h.collection, label: h.label, count: h.files.length, files: h.files });
115
+ }
91
116
  }
92
117
  return rows;
93
118
  }
@@ -103,7 +103,7 @@ export function DocumentsLevel() {
103
103
  ) : (
104
104
  <>
105
105
  {nested.map((c) => (
106
- <PagesSection key={c.name} collection={c} documents={documents.filter((d) => d.collection === c.name)} />
106
+ <PagesSection key={c.name} collection={c} documents={documents.filter((d) => d.collection === c.name)} all={documents} />
107
107
  ))}
108
108
  {flat.length > 0 ? (
109
109
  <section className="grp" aria-label="Collections">
@@ -131,10 +131,14 @@ export function DocumentsLevel() {
131
131
  );
132
132
  }
133
133
 
134
- function PagesSection({ collection, documents }: { collection: EditorCollection; documents: ListedDocument[] }) {
134
+ function PagesSection({ collection, documents, all }: { collection: EditorCollection; documents: ListedDocument[]; all: ListedDocument[] }) {
135
135
  const editor = useEditor();
136
136
  const open = useStore(editor.ui, (u) => u.open);
137
- const rows = treeRows(collection.name, documents, open);
137
+ // Collections under pages (Decision 4): each collection a page here holds, as a row under it.
138
+ const held = editor.site.collections
139
+ .filter((c) => c.heldBy === collection.name && c.holder?.collection === collection.name)
140
+ .map((c) => ({ collection: c.name, label: c.label, holder: c.holder!.path, files: all.filter((d) => d.collection === c.name).map((d) => d.file) }));
141
+ const rows = treeRows(collection.name, documents, open, held);
138
142
  return (
139
143
  <section className="grp" aria-label={collection.label} data-collection={collection.name}>
140
144
  <div className="grp-h">
@@ -155,6 +159,22 @@ function PagesSection({ collection, documents }: { collection: EditorCollection;
155
159
  function TreeNode({ row, collection }: { row: TreeRow; collection: EditorCollection }) {
156
160
  const editor = useEditor();
157
161
  const depth = { ['--depth' as string]: row.depth };
162
+ if (row.kind === 'held') {
163
+ const held = editor.site.collections.find((c) => c.name === row.collection);
164
+ return (
165
+ <div className="tnode" data-tnode-held={row.collection}>
166
+ <button type="button" className="drow t coll is-held" style={depth} data-held={row.collection} aria-label={`${row.label}, ${row.count}`} title={`${row.label}, held by this page: its documents are at its address`} onClick={() => editor.openCollection(row.collection)}>
167
+ <Icon name={docIcon(held)} />
168
+ <span className="nm">{row.label}</span>
169
+ <span className="meta">
170
+ {editor.mode === 'live' ? <Bubble files={row.files} /> : <span data-slot="bubble" hidden />}
171
+ <span className="cnt">{row.count}</span>
172
+ </span>
173
+ <Icon name="chev-r" size="sm" />
174
+ </button>
175
+ </div>
176
+ );
177
+ }
158
178
  if (row.kind === 'group') {
159
179
  return (
160
180
  <div className="tnode" data-tnode-group={row.path}>
@@ -344,17 +364,42 @@ function SubmissionsRow() {
344
364
  function CollectionRow({ collection, documents }: { collection: EditorCollection; documents: ListedDocument[] }) {
345
365
  const editor = useEditor();
346
366
  const n = documents.length;
367
+ // Collections under pages (Decision 13): a held collection no page holds (or two do) has no addresses; its entry says why.
368
+ const problem = collection.problem ?? null;
347
369
  return (
348
- <button type="button" className="drow coll" data-coll={collection.name} aria-label={`${collection.label}, ${n}`} onClick={() => editor.openCollection(collection.name)}>
349
- <Icon name={docIcon(collection)} />
350
- <span className="nm">{collection.label}</span>
351
- <span className="meta">
352
- {/* M4: presence and waiting dots inside the collection, bubbled up. */}
353
- {editor.mode === 'live' ? <Bubble files={documents.map((d) => d.file)} /> : <span data-slot="bubble" hidden />}
354
- <span className="cnt">{n}</span>
355
- </span>
356
- <Icon name="chev-r" size="sm" />
357
- </button>
370
+ <>
371
+ <button
372
+ type="button"
373
+ className={problem ? 'drow coll is-warned' : 'drow coll'}
374
+ data-coll={collection.name}
375
+ aria-label={`${collection.label}, ${n}${problem ? ', has no addresses' : ''}`}
376
+ title={problem ?? undefined}
377
+ onClick={() => editor.openCollection(collection.name)}
378
+ >
379
+ <Icon name={docIcon(collection)} />
380
+ <span className="nm">{collection.label}</span>
381
+ <span className="meta">
382
+ {problem ? (
383
+ <span className="wmark" data-warning="not-held">
384
+ <Icon name="warn" size="xs" />
385
+ </span>
386
+ ) : null}
387
+ {/* M4: presence and waiting dots inside the collection, bubbled up. */}
388
+ {editor.mode === 'live' ? <Bubble files={documents.map((d) => d.file)} /> : <span data-slot="bubble" hidden />}
389
+ <span className="cnt">{n}</span>
390
+ </span>
391
+ <Icon name="chev-r" size="sm" />
392
+ </button>
393
+ {problem ? (
394
+ <div className="note warn tight doc-why" role="note" data-why={`coll-${collection.name}`}>
395
+ <Icon name="warn" />
396
+ <div>
397
+ <b>{collection.label} has no addresses</b>
398
+ <p>{problem}</p>
399
+ </div>
400
+ </div>
401
+ ) : null}
402
+ </>
358
403
  );
359
404
  }
360
405
 
@@ -107,12 +107,25 @@ function GitHubAccessNote() {
107
107
  <Icon name="warn" />
108
108
  <div>
109
109
  <b>{access.title}</b>
110
- <p>{access.message}</p>
110
+ <p>{withLink(access.message, access.link ?? null)}</p>
111
111
  </div>
112
112
  </div>
113
113
  );
114
114
  }
115
115
 
116
+ /** The sentence as it came, with `link` as a link where it names it (or after it); the words are never changed. */
117
+ function withLink(message: string, link: string | null) {
118
+ if (!link) return message;
119
+ const a = (key: number) => (
120
+ <a key={key} href={link} target="_blank" rel="noopener noreferrer">
121
+ {link}
122
+ </a>
123
+ );
124
+ const parts = message.split(link);
125
+ if (parts.length === 1) return [message, ' ', a(0)];
126
+ return parts.flatMap((part, i) => (i === 0 ? [part] : [a(i), part]));
127
+ }
128
+
116
129
  function KeepNotes() {
117
130
  const editor = useEditor();
118
131
  const state = useStore(editor.keep, (k) => k.state);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * POST /_caracms/move: `{ collection, path, to }` → MoveResponse, or `{ error, code }`
2
+ * POST /_caracms/move: `{ collection, path, to, redirect? }` → MoveResponse, or `{ error, code }`
3
3
  * (pre-M7 item 8; shapes: ../types.ts → MoveRequest). The rules are in ../server/move.ts;
4
4
  * the store is the mode's (server/mode.ts), as for create and delete: on disk in local
5
5
  * mode, autosaves under this tab's locks in live mode, where the request carries
@@ -7,6 +7,7 @@ import { createHash } from 'node:crypto';
7
7
  import { relative } from 'node:path';
8
8
  import type { Brand, ResolvedSite } from '../../config.ts';
9
9
  import { DocumentError } from '../../content.ts';
10
+ import { heldBases, withHeldBases } from '../../held.ts';
10
11
  import { inboxFromAddress, refFromAddress } from '../addresses.ts';
11
12
  import { handleSubmissions, SUBMISSIONS_PREFIX, submissionsSetupQuick, type SubmissionResponse, type SubmissionsListResponse } from '../../forms/inbox.ts';
12
13
  import { ENDPOINTS, type AdminProps, type DocRef, type EditorSite, type ListedDocument, type LiveProps, type MediaSetup, type EditorForm, type Person, type StatusResponse } from '../types.ts';
@@ -34,7 +35,24 @@ export function homeOf(site: Pick<ResolvedSite, 'collections'>, documents: reado
34
35
  */
35
36
  export function editorSite(site: ResolvedSite, documents: readonly ListedDocument[], host: string, brand: Brand): EditorSite {
36
37
  // Every kind of collection (M5a Phase 5): the site's own in config order, then `shared` and `settings`.
37
- const collections = Object.values(site.collections).map(({ name, label, kind, urlBase, nested, fields, columns, sort, view, blocks }) => ({ name, label, kind, urlBase, nested, fields, columns, sort: sort ?? null, view: view ?? null, blocks: blocks ?? null }));
38
+ // Collections under pages (Decision 7): a held collection's urlBase is resolved from the listing, so the browser never works one out.
39
+ const bases = heldBases(site.collections, documents);
40
+ const collections = Object.values(withHeldBases(site, bases).collections).map(({ name, label, kind, urlBase, nested, fields, columns, sort, view, blocks, heldBy }) => {
41
+ const held = heldBy ? bases[name] : undefined;
42
+ return {
43
+ name,
44
+ label,
45
+ kind,
46
+ urlBase,
47
+ nested,
48
+ fields,
49
+ columns,
50
+ sort: sort ?? null,
51
+ view: view ?? null,
52
+ blocks: blocks ?? null,
53
+ ...(held ? { heldBy, holder: held.holder && !held.problem ? { collection: held.holder.collection, path: held.holder.path } : null, problem: held.problem } : {}),
54
+ };
55
+ });
38
56
  return {
39
57
  collections,
40
58
  allCollections: collections,