caracms 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +73 -3
  2. package/bin/templates/catch-all.astro.txt +10 -9
  3. package/package.json +1 -1
  4. package/skills/caracms-adopt/SKILL.md +21 -4
  5. package/skills/caracms-blocks/SKILL.md +36 -13
  6. package/skills/caracms-content/SKILL.md +27 -6
  7. package/skills/caracms-tina/SKILL.md +53 -11
  8. package/skills/caracms-tina/field-kinds.md +2 -0
  9. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  10. package/src/cli/validate.ts +103 -12
  11. package/src/code-routes.ts +26 -0
  12. package/src/components/Head.astro +5 -5
  13. package/src/components/RenderDocument.astro +3 -3
  14. package/src/components/sitemap.xml.ts +5 -4
  15. package/src/config.ts +57 -11
  16. package/src/content-schema.ts +6 -2
  17. package/src/content.ts +188 -27
  18. package/src/document.ts +13 -2
  19. package/src/editor/README.md +3 -0
  20. package/src/editor/addresses.ts +6 -1
  21. package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
  22. package/src/editor/client/arrange/model.ts +659 -0
  23. package/src/editor/client/arrange/store.ts +335 -0
  24. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  25. package/src/editor/client/documents/actions.ts +75 -19
  26. package/src/editor/client/documents/dialogs.tsx +137 -18
  27. package/src/editor/client/editor.ts +78 -6
  28. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  29. package/src/editor/client/nav/DocRow.tsx +8 -1
  30. package/src/editor/client/nav/tree.ts +26 -1
  31. package/src/editor/client/pane/Pane.tsx +3 -0
  32. package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
  33. package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
  34. package/src/editor/client/pane/parts.tsx +14 -1
  35. package/src/editor/client/shell/Stage.tsx +8 -4
  36. package/src/editor/routes/admin.astro +1 -1
  37. package/src/editor/routes/arrange.ts +17 -0
  38. package/src/editor/routes/move.ts +1 -1
  39. package/src/editor/server/admin-props.ts +24 -4
  40. package/src/editor/server/arrange.ts +245 -0
  41. package/src/editor/server/create.ts +13 -2
  42. package/src/editor/server/drafts-store.ts +55 -10
  43. package/src/editor/server/move.ts +300 -81
  44. package/src/editor/styles/editor.css +5 -0
  45. package/src/editor/styles/levels/arrange.css +152 -0
  46. package/src/editor/styles/levels/collections.css +2 -0
  47. package/src/editor/styles/levels/m5a-documents.css +15 -0
  48. package/src/editor/types.ts +83 -7
  49. package/src/held.ts +116 -0
  50. package/src/index.ts +6 -2
  51. package/src/live/README.md +3 -2
  52. package/src/live/auth/middleware.ts +11 -0
  53. package/src/live/autosaves.ts +88 -1
  54. package/src/live/cloud.ts +131 -0
  55. package/src/live/drafts-store.ts +170 -77
  56. package/src/live/editor.ts +26 -4
  57. package/src/live/git.ts +63 -12
  58. package/src/live/github.ts +69 -7
  59. package/src/live/mode.ts +42 -6
  60. package/src/live/runtime.ts +18 -2
  61. package/src/page-order.ts +253 -0
  62. package/src/redirects.ts +64 -1
  63. package/src/relink.ts +40 -9
  64. package/src/reserved.ts +3 -2
  65. package/src/serialize.ts +18 -1
  66. package/src/slug.ts +15 -4
@@ -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
 
@@ -43,7 +44,9 @@ export type Dialog =
43
44
  /** M6 Decision 9: a submission's Delete…, permanent, so it asks first (the mockup's #submission-delete). */
44
45
  | { kind: 'sub-delete'; id: string; who: string; files: number }
45
46
  /** M6c G4 Decision 9: Replace… on a file in the library (its key). */
46
- | { kind: 'replace'; key: string };
47
+ | { kind: 'replace'; key: string }
48
+ /** Arrange pages (Decision 15): leaving the screen with something arranged; `go` leaves, dropping it. */
49
+ | { kind: 'arrange-leave'; go: () => void };
47
50
 
48
51
  const dialogs = new WeakMap<Editor, Store<Dialog | null>>();
49
52
 
@@ -80,13 +83,21 @@ export function DocumentDialogs() {
80
83
  return <DeleteSubmission key={dialog.id} {...dialog} />;
81
84
  case 'replace':
82
85
  return <ReplaceDialog key={dialog.key} fileKey={dialog.key} />;
86
+ case 'arrange-leave':
87
+ return <LeaveArrange go={dialog.go} />;
83
88
  }
84
89
  }
85
90
 
86
91
  /** The dialog frame: the scrim, focus on its first control, Esc closing it alone, focus back where it was. */
87
- export function Frame(props: { alert?: boolean; label: string; describedBy?: string; children: ReactNode }) {
92
+ /**
93
+ * `returnTo`: where focus goes when the dialog closes and no control opened it (the browser's
94
+ * Back, say, leaves it on the page body); otherwise it goes back to the control that did.
95
+ */
96
+ export function Frame(props: { alert?: boolean; label: string; describedBy?: string; returnTo?: () => HTMLElement | null; children: ReactNode }) {
88
97
  const editor = useEditor();
89
98
  const box = useRef<HTMLDivElement>(null);
99
+ const returnTo = useRef(props.returnTo);
100
+ returnTo.current = props.returnTo;
90
101
  useEffect(() => {
91
102
  const before = document.activeElement as HTMLElement | null;
92
103
  const first =
@@ -118,7 +129,8 @@ export function Frame(props: { alert?: boolean; label: string; describedBy?: str
118
129
  window.addEventListener('keydown', onKey, true);
119
130
  return () => {
120
131
  window.removeEventListener('keydown', onKey, true);
121
- if (before?.isConnected) before.focus({ preventScroll: true });
132
+ const opener = before?.isConnected && before !== document.body ? before : (returnTo.current?.() ?? null);
133
+ if (opener?.isConnected) opener.focus({ preventScroll: true });
122
134
  };
123
135
  }, [editor]);
124
136
  return (
@@ -297,7 +309,18 @@ function NewDocument(props: { collection: string; inside?: string; at?: string }
297
309
  /**
298
310
  * A document's new address: for nested Pages the group it sits in ("Inside", New page's
299
311
  * 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.
312
+ * New page's rules checked before asking the server.
313
+ *
314
+ * **Everything under it moves with it** (collections under pages, Decision 9): the pages
315
+ * under it at any depth, and the documents of a collection it (or a page under it) holds.
316
+ * Then the dialog lists every address that changes ("Its 1 page moves with it",
317
+ * "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
318
+ * the old address (it doesn't move), and has one tick for the lot. A page with nothing
319
+ * under it keeps the single-page dialog.
320
+ *
321
+ * "Leave a redirect from …" ("Leave redirects from the old addresses" for several), ticked
322
+ * to start with, says whether the old addresses redirect; it shows only when there's one to
323
+ * leave (a live address, not one an earlier move since the last Publish already redirects).
301
324
  */
302
325
  function MoveDocument({ docRef }: { docRef: DocRef }) {
303
326
  const editor = useEditor();
@@ -311,8 +334,9 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
311
334
  const [part, setPart] = useState(docRef.path.slice(slash + 1));
312
335
  const [error, setError] = useState<string | null>(null);
313
336
  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]);
337
+ const [redirect, setRedirect] = useState(true);
338
+ // Every page but this one and the pages under it: a page can't go under itself.
339
+ const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents).filter((o) => o.path !== docRef.path && !o.path.startsWith(`${docRef.path}/`)) : []), [collection, documents, docRef.path]);
316
340
  if (!doc || !collection || !doc.url) return null;
317
341
  const nested = collection.nested;
318
342
  const newGroup = nested && inside === NEW_GROUP;
@@ -323,8 +347,10 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
323
347
  const url = addressFor(collection, path);
324
348
  const base = addressFor(collection, folder ? `${folder}/` : '') ?? '/';
325
349
  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).
350
+ const moving = addressesMoving(editor.site.collections, docRef, path, documents);
351
+ const many = moving.length > 1;
352
+ const others = documents.filter((d) => !moving.some((m) => m.doc === d));
353
+ // New page's rules, before asking the server (it applies the same, and checks every address that changes).
328
354
  const local = (() => {
329
355
  if (same) return null;
330
356
  if (newGroup && !slugify(group)) return group.trim() ? NO_ADDRESS_MESSAGE : null;
@@ -334,21 +360,38 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
334
360
  })();
335
361
  const shown = error ?? local;
336
362
  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');
363
+ // Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from,
364
+ // and its redirect from there is there already, following this move.
365
+ const pendingOf = (d: ListedDocument) => (waiting ?? []).find((w) => w.collection === d.collection && w.path === d.path && (w.change === 'new' || !!w.movedFrom));
366
+ const pending = pendingOf(doc);
339
367
  const liveAddress = pending ? (pending.movedFrom ?? null) : doc.url;
340
- const kids = nested ? pagesUnder(collection.name, docRef.path, documents) : [];
368
+ const choose = many ? moving.some((m) => !pendingOf(m.doc)) : !pending;
341
369
  const host = editor.site.host;
342
370
  const confirm = async () => {
343
371
  if (blocked) return;
344
372
  setBusy(true);
345
373
  setError(null);
346
- const answer = await moveDocument(editor, docRef, path, doc.label);
374
+ const answer = await moveDocument(editor, docRef, path, doc.label, !choose || redirect);
347
375
  setBusy(false);
348
376
  if (answer.ok) closeDialog(editor);
349
377
  else setError(answer.error);
350
378
  };
351
- const intro = liveAddress ? `${liveAddress} will redirect here.${editor.mode === 'live' ? ' Goes live at the next Publish.' : ''}` : null;
379
+ const publish = editor.mode === 'live' ? ' Goes live at the next Publish.' : '';
380
+ const intro = !many && liveAddress && !choose ? `${liveAddress} will redirect here.${publish}` : null;
381
+ // What moves with it: the pages under it, then each held collection's documents.
382
+ const under = moving.filter((m) => !m.held && m.doc !== doc);
383
+ const heldBy = new Map<string, number>();
384
+ for (const m of moving) if (m.held) heldBy.set(m.doc.collection, (heldBy.get(m.doc.collection) ?? 0) + 1);
385
+ const labelOf = (name: string) => editor.site.collections.find((c) => c.name === name)?.label ?? name;
386
+ // "Its 1 page moves with it." "Its 24 journal entries move with it." "Its 2 pages and 24 journal entries move with it."
387
+ const heldParts = [...heldBy].map(([name, n]) => `${n} ${n === 1 ? singular({ kind: 'sections', nested: false, label: labelOf(name) }) : documentsNoun(labelOf(name))}`);
388
+ const parts = [...(under.length ? [`${under.length} page${under.length === 1 ? '' : 's'}`] : []), ...heldParts];
389
+ if (parts.length) parts[0] = `Its ${parts[0]}`;
390
+ const one = moving.length === 2 && parts.length === 1;
391
+ const withIt = `${andList(parts)} move${one ? 's' : ''} with it.`;
392
+ // A route of the site's own code under the old address stays where it is (Decision 10).
393
+ const oldBase = `${doc.url.replace(/\/+$/, '')}/`;
394
+ const routes = many ? (editor.site.codeRoutes ?? []).filter((r) => r.url.toLowerCase().startsWith(oldBase.toLowerCase())) : [];
352
395
  return (
353
396
  <Frame label="move">
354
397
  <form
@@ -408,10 +451,49 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
408
451
  </p>
409
452
  )}
410
453
  </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>
454
+ {many ? (
455
+ <div className="field" data-moving={moving.length}>
456
+ <p className="field-help">
457
+ {withIt} {same ? 'The addresses now:' : 'The new addresses:'}
458
+ </p>
459
+ <ul className="mv-list" aria-label="Every address that changes" tabIndex={0}>
460
+ {moving.map((m) => (
461
+ <li key={`${m.doc.collection}/${m.doc.path}`} className="mono" data-to={m.toUrl ?? ''}>
462
+ {same ? m.doc.url : (m.toUrl ?? '…')}
463
+ </li>
464
+ ))}
465
+ </ul>
466
+ {routes.length ? (
467
+ <p className="field-help" data-routes={routes.length}>
468
+ <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'}.
469
+ </p>
470
+ ) : null}
471
+ </div>
472
+ ) : null}
473
+ {choose ? (
474
+ <div className="field">
475
+ <label className="check">
476
+ <input type="checkbox" id="mv-redirect" checked={redirect} aria-describedby="mv-redirect-h" onChange={(e) => setRedirect(e.target.checked)} />
477
+ <span>
478
+ {many ? (
479
+ 'Leave redirects from the old addresses'
480
+ ) : (
481
+ <>
482
+ Leave a redirect from <span className="mono">{doc.url}</span>
483
+ </>
484
+ )}
485
+ </span>
486
+ </label>
487
+ <p className="field-help" id="mv-redirect-h">
488
+ {many
489
+ ? redirect
490
+ ? `Anyone with an old address lands on the new one.${publish}`
491
+ : 'Nothing will be at the old addresses: links to them from elsewhere will find no page.'
492
+ : redirect
493
+ ? `Anyone with the old address lands here.${publish}`
494
+ : `Nothing will be at ${doc.url}: links to it from elsewhere will find no page.`}
495
+ </p>
496
+ </div>
415
497
  ) : null}
416
498
  <div className="acts">
417
499
  <button type="button" className="btn" onClick={() => closeDialog(editor)}>
@@ -569,6 +651,24 @@ function DeleteDocument({ docRef }: { docRef: DocRef }) {
569
651
  const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
570
652
  const collection = editor.site.collections.find((c) => c.name === docRef.collection);
571
653
  if (!doc || !collection) return null;
654
+ // Collections under pages (Decision 12): a page that holds a collection isn't deleted, and says so instead of asking.
655
+ const held = doc.holds ? editor.site.collections.find((c) => c.name === doc.holds && c.heldBy === collection.name) : undefined;
656
+ if (held) {
657
+ return (
658
+ <Frame alert label="holds" describedBy="dlg-d">
659
+ <div className="icon-h" style={{ background: 'var(--status-warning-wash)', color: 'var(--status-warning)' }}>
660
+ <Icon name="warn" />
661
+ </div>
662
+ <h2 id="dlg-h">{doc.label} can’t be deleted</h2>
663
+ <p id="dlg-d">{holdsMessage(doc.label, held.label)}</p>
664
+ <div className="acts">
665
+ <button type="button" className="btn ink" data-act="holds-ok" onClick={() => closeDialog(editor)}>
666
+ OK
667
+ </button>
668
+ </div>
669
+ </Frame>
670
+ );
671
+ }
572
672
  const shared = collection.kind === 'shared';
573
673
  const kids = collection.nested ? pagesUnder(collection.name, doc.path, documents) : [];
574
674
  const host = editor.site.host;
@@ -680,6 +780,25 @@ function DeleteSubmission({ id, who, files }: { id: string; who: string; files:
680
780
  }
681
781
 
682
782
  /** A shared block that pages still use can't be deleted (Decision 8): where it's used, each a way there. */
783
+ /** Arrange pages (Decision 15; the mockup's #leave): what's arranged isn't saved anywhere, so leaving asks first. */
784
+ function LeaveArrange({ go }: { go: () => void }) {
785
+ const editor = useEditor();
786
+ return (
787
+ <Frame label="arrange-leave" returnTo={() => document.querySelector<HTMLElement>('[data-pane-level="arrange"] [data-act="cancel"]')}>
788
+ <h2 id="dlg-h">Leave without applying?</h2>
789
+ <p>What you’ve arranged isn’t saved anywhere: leave now and it’s gone. Nothing on the site has changed.</p>
790
+ <div className="acts">
791
+ <button type="button" className="btn" data-act="leave-stay" onClick={() => closeDialog(editor)}>
792
+ Keep arranging
793
+ </button>
794
+ <button type="button" className="btn ink" data-act="leave-go" onClick={go}>
795
+ Leave without applying
796
+ </button>
797
+ </div>
798
+ </Frame>
799
+ );
800
+ }
801
+
683
802
  function InUse({ docRef, usedOn }: { docRef: DocRef; usedOn?: CreateDeleteRefusal['usedOn'] }) {
684
803
  const editor = useEditor();
685
804
  const documents = useStore(editor.documents);
@@ -30,7 +30,8 @@
30
30
  * editor.editHere() / useMine() / discardMine() / publish() / tryAgain() /
31
31
  * dismissNotice(id) / signOut() / signInAgain()
32
32
  */
33
- import { addressOf, inboxAddress, inboxFromAddress, refFromAddress } from '../addresses.ts';
33
+ import { ARRANGE_ADDRESS, addressOf, inboxAddress, inboxFromAddress, isArrangeAddress, refFromAddress } from '../addresses.ts';
34
+ import { heldBases } from '../../held.ts';
34
35
  import {
35
36
  docId,
36
37
  ENDPOINTS,
@@ -63,7 +64,7 @@ import { createKeeper, type HeldView, type Keeper, type KeepStatus } from './sta
63
64
  import { createLiveSession, tabId, type LiveSession, type LiveState } from './state/live.ts';
64
65
  import { createStore, type Store } from './state/store.ts';
65
66
 
66
- export type PaneLevel = 'documents' | 'collection' | 'document' | 'block' | 'media' | 'submissions';
67
+ export type PaneLevel = 'documents' | 'collection' | 'document' | 'block' | 'media' | 'submissions' | 'arrange';
67
68
 
68
69
  /**
69
70
  * M5b: Choose… (or Replace…, or a list's Add image) opened the library as a picker for
@@ -247,6 +248,18 @@ export interface Editor {
247
248
  * coming from elsewhere, replaced within the inbox.
248
249
  */
249
250
  openInbox(id?: string | null, options?: { history?: 'push' | 'replace' | 'none' }): void;
251
+ /**
252
+ * Arrange pages (Decisions 2, 15, 16): the arrange screen in the preview's place, its level
253
+ * in the pane, at `/admin/arrange` (pushed when coming from elsewhere). It starts from the
254
+ * listing as it is each time (client/arrange/store.ts).
255
+ */
256
+ openArrange(options?: { history?: 'push' | 'replace' | 'none' }): void;
257
+ /**
258
+ * Arrange pages (Decision 15): the screen's say when the tab would leave it, set by
259
+ * client/arrange/store.ts. `ask(go)` returns true when it took over (anything arranged:
260
+ * it asks first, then calls `go`); `up()` is Esc there. Null until the screen is first shown.
261
+ */
262
+ arrangeGuard: { ask(go: () => void): boolean; up(): void } | null;
250
263
 
251
264
  openDocument(ref: DocRef, options?: { history?: 'push' | 'replace' | 'none' }): Promise<void>;
252
265
  openBlock(key: string, field?: string | null, options?: { picked?: boolean }): void;
@@ -298,6 +311,24 @@ export interface Editor {
298
311
  timeZone(): string;
299
312
  }
300
313
 
314
+ /**
315
+ * Collections under pages (Decision 7): each held collection's base, worked out again from a
316
+ * fresh listing (held.ts), so a holder that moved takes its collection's addresses with it in
317
+ * every dialog and table without a reload. The payload's own objects are updated in place.
318
+ */
319
+ export function followHolders(site: Pick<EditorSite, 'collections' | 'allCollections'>, listed: readonly ListedDocument[]): void {
320
+ if (!site.collections.some((c) => c.heldBy)) return;
321
+ const bases = heldBases(Object.fromEntries(site.collections.map((c) => [c.name, c])), listed);
322
+ for (const b of Object.values(bases)) {
323
+ for (const c of [...site.collections, ...(site.allCollections ?? [])]) {
324
+ if (c.name !== b.collection) continue;
325
+ c.urlBase = b.base;
326
+ c.holder = b.holder ? { collection: b.holder.collection, path: b.holder.path } : null;
327
+ c.problem = b.problem;
328
+ }
329
+ }
330
+ }
331
+
301
332
  export function createEditor(props: AdminProps, options: { client: boolean }): Editor {
302
333
  const { site } = props;
303
334
  const opened = props.opened;
@@ -325,6 +356,8 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
325
356
  const submissions = createStore<SubmissionsSetup>(props.submissions ?? { unread: 0 });
326
357
  // M6: /admin/submissions[/<id>] opens on the inbox, with Home under it.
327
358
  if (props.inbox) view.update({ level: 'submissions', inbox: { id: props.inbox.id } });
359
+ // Arrange pages: /admin/arrange opens on the screen, with Home under it.
360
+ if (props.arrange) view.update({ level: 'arrange' });
328
361
 
329
362
  // Live mode (M4): the poll and the server calls; a tab id that lasts the tab's life.
330
363
  const tab = liveProps ? (options.client ? tabId() : 'server') : null;
@@ -570,7 +603,8 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
570
603
 
571
604
  function setTitle() {
572
605
  if (typeof document === 'undefined') return;
573
- const what = view.get().level === 'submissions' ? 'Submissions' : doc.get().document.title || doc.get().entry.label;
606
+ const level = view.get().level;
607
+ const what = level === 'submissions' ? 'Submissions' : level === 'arrange' ? 'Arrange pages' : doc.get().document.title || doc.get().entry.label;
574
608
  document.title = `${what} · ${brand.get().name}`;
575
609
  }
576
610
 
@@ -599,7 +633,20 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
599
633
  submissions,
600
634
  inboxFirst: props.inboxFirst ?? null,
601
635
 
636
+ arrangeGuard: null,
637
+
638
+ openArrange({ history = 'push' } = {}) {
639
+ view.update({ level: 'arrange', inbox: null, media: null, collection: null, block: null, field: null, picked: false, pop: null });
640
+ if (history !== 'none' && typeof window !== 'undefined' && location.pathname !== ARRANGE_ADDRESS) {
641
+ if (history === 'push') window.history.pushState({ caracms: 'arrange' }, '', ARRANGE_ADDRESS);
642
+ else window.history.replaceState({ caracms: 'arrange' }, '', ARRANGE_ADDRESS);
643
+ }
644
+ setTitle();
645
+ },
646
+
602
647
  openInbox(id = null, { history } = {}) {
648
+ // Leaving the arrange screen with something arranged asks first (Decision 15).
649
+ if (editor.arrangeGuard?.ask(() => editor.openInbox(id, { history }))) return;
603
650
  const before = view.get();
604
651
  const within = before.level === 'submissions';
605
652
  view.update({ level: 'submissions', inbox: { id }, block: null, field: null, picked: false, pop: null, media: null });
@@ -623,6 +670,7 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
623
670
  },
624
671
 
625
672
  async openDocument(ref, { history = 'push' } = {}) {
673
+ if (editor.arrangeGuard?.ask(() => void editor.openDocument(ref, { history }))) return;
626
674
  const current = doc.get().ref;
627
675
  if (sameDoc(ref, current) && !view.get().opening) {
628
676
  view.update({ level: 'document', block: null, field: null, picked: false, pop: null, inbox: null });
@@ -688,6 +736,11 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
688
736
 
689
737
  goUp() {
690
738
  const { level, media, inbox } = view.get();
739
+ // Arrange pages: the review → arranging; arranging → leave (asking first when anything is arranged).
740
+ if (level === 'arrange') {
741
+ editor.arrangeGuard?.up();
742
+ return true;
743
+ }
691
744
  const { kind } = doc.get().document;
692
745
  // M6: the inbox: an open submission → the list; the list → documents.
693
746
  if (level === 'submissions') {
@@ -743,7 +796,9 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
743
796
  async refreshDocuments() {
744
797
  const response = await fetch(ENDPOINTS.documents, { headers: { Accept: 'application/json' } });
745
798
  if (!response.ok) return;
746
- documents.set((await response.json()) as ListedDocument[]);
799
+ const listed = (await response.json()) as ListedDocument[];
800
+ followHolders(site, listed);
801
+ documents.set(listed);
747
802
  // M6 Decision 9: the unread count rides along (absent when it couldn't be read).
748
803
  const unread = response.headers.get(UNREAD_HEADER);
749
804
  if (unread !== null && /^\d+$/.test(unread)) submissions.set({ unread: Number(unread) });
@@ -961,7 +1016,10 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
961
1016
  });
962
1017
  };
963
1018
  const waitingKey = (st: LiveState) => (st.status?.waiting ?? []).map((w) => `${w.file}:${w.change}`).sort().join('\n');
964
- if (liveSession) stops.push(liveState.subscribe((next, previous) => next.status && previous.status && waitingKey(next) !== waitingKey(previous) && refresh()));
1019
+ // Arrange pages (Decision 18): an order-only Apply elsewhere changes no waiting key when Site Settings was
1020
+ // already waiting, so the saved order's stamp is watched too.
1021
+ const orderKey = (st: LiveState) => st.status?.pageOrderStamp ?? '';
1022
+ if (liveSession) stops.push(liveState.subscribe((next, previous) => next.status && previous.status && (waitingKey(next) !== waitingKey(previous) || orderKey(next) !== orderKey(previous)) && refresh()));
965
1023
  stops.push(keep.subscribe((next, previous) => next.state === 'kept' && previous.state !== 'kept' && refresh()));
966
1024
 
967
1025
  // The cookie follows the sidebar's per-browser state (Decision 20).
@@ -970,9 +1028,23 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
970
1028
 
971
1029
  // Addresses (Decision 19): the address names the open document; back and forward open theirs.
972
1030
  // M6: the inbox's own address (`/admin/submissions[/<id>]`) stays as it is.
973
- const canonical = view.get().level === 'submissions' ? inboxAddress(view.get().inbox?.id ?? null) : addressOf(doc.get().ref, site.home);
1031
+ const canonical = view.get().level === 'submissions' ? inboxAddress(view.get().inbox?.id ?? null) : view.get().level === 'arrange' ? ARRANGE_ADDRESS : addressOf(doc.get().ref, site.home);
974
1032
  if (location.pathname !== canonical) history.replaceState({ caracms: id }, '', canonical + location.search);
975
1033
  const onPopState = () => {
1034
+ if (isArrangeAddress(location.pathname)) {
1035
+ editor.openArrange({ history: 'none' });
1036
+ return;
1037
+ }
1038
+ // Back from the arrange screen with something arranged: it stays, and asks first (Decision 15).
1039
+ if (view.get().level === 'arrange' && editor.arrangeGuard) {
1040
+ // The screen's entry is put back while it asks; leaving steps back off it again, so the
1041
+ // history is as a plain Back leaves it (the arrangement is gone by then, so it doesn't ask twice).
1042
+ const asked = editor.arrangeGuard.ask(() => history.back());
1043
+ if (asked) {
1044
+ history.pushState({ caracms: 'arrange' }, '', ARRANGE_ADDRESS);
1045
+ return;
1046
+ }
1047
+ }
976
1048
  const inbox = inboxFromAddress(location.pathname);
977
1049
  if (inbox && inbox !== 'bad') {
978
1050
  editor.openInbox(inbox.id, { history: 'none' });
@@ -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
  }
@@ -17,6 +17,7 @@ import { DocumentLevel } from './levels/DocumentLevel.tsx';
17
17
  import { DocumentsLevel } from './levels/DocumentsLevel.tsx';
18
18
  import { MediaLevel } from './levels/MediaLevel.tsx';
19
19
  import { SubmissionsLevel } from './levels/SubmissionsLevel.tsx';
20
+ import { ArrangeLevel } from './levels/ArrangeLevel.tsx';
20
21
 
21
22
  export const LEVELS: Record<PaneLevel, ComponentType> = {
22
23
  documents: DocumentsLevel,
@@ -27,6 +28,8 @@ export const LEVELS: Record<PaneLevel, ComponentType> = {
27
28
  media: MediaLevel,
28
29
  // M6: the inbox (Site-wide → Submissions).
29
30
  submissions: SubmissionsLevel,
31
+ // Arrange pages: the screen's controls, then its review (Decision 16).
32
+ arrange: ArrangeLevel,
30
33
  };
31
34
 
32
35
  /**