caracms 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +59 -3
  2. package/bin/caracms.mjs +30 -6
  3. package/bin/gate.mjs +157 -6
  4. package/bin/templates/catch-all.astro.txt +10 -9
  5. package/package.json +1 -1
  6. package/skills/caracms-adopt/SKILL.md +21 -4
  7. package/skills/caracms-blocks/SKILL.md +36 -13
  8. package/skills/caracms-content/SKILL.md +27 -6
  9. package/skills/caracms-tina/SKILL.md +53 -11
  10. package/skills/caracms-tina/field-kinds.md +2 -0
  11. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  12. package/src/cli/validate.ts +51 -10
  13. package/src/code-routes.ts +26 -0
  14. package/src/components/Head.astro +5 -5
  15. package/src/components/RenderDocument.astro +3 -3
  16. package/src/components/sitemap.xml.ts +5 -4
  17. package/src/config.ts +57 -11
  18. package/src/content-schema.ts +5 -2
  19. package/src/content.ts +148 -8
  20. package/src/document.ts +8 -0
  21. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  22. package/src/editor/client/documents/actions.ts +61 -16
  23. package/src/editor/client/documents/dialogs.tsx +104 -15
  24. package/src/editor/client/editor.ts +22 -1
  25. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  26. package/src/editor/client/nav/DocRow.tsx +8 -1
  27. package/src/editor/client/nav/tree.ts +26 -1
  28. package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
  29. package/src/editor/client/pane/parts.tsx +14 -1
  30. package/src/editor/routes/move.ts +1 -1
  31. package/src/editor/server/admin-props.ts +19 -1
  32. package/src/editor/server/create.ts +13 -2
  33. package/src/editor/server/drafts-store.ts +24 -9
  34. package/src/editor/server/move.ts +266 -81
  35. package/src/editor/styles/editor.css +5 -0
  36. package/src/editor/styles/levels/collections.css +2 -0
  37. package/src/editor/styles/levels/m5a-documents.css +15 -0
  38. package/src/editor/types.ts +31 -5
  39. package/src/held.ts +116 -0
  40. package/src/index.ts +4 -2
  41. package/src/live/README.md +3 -2
  42. package/src/live/auth/middleware.ts +11 -0
  43. package/src/live/autosaves.ts +88 -1
  44. package/src/live/cloud.ts +131 -0
  45. package/src/live/drafts-store.ts +122 -87
  46. package/src/live/editor.ts +22 -4
  47. package/src/live/git.ts +63 -12
  48. package/src/live/github.ts +69 -7
  49. package/src/live/mode.ts +42 -6
  50. package/src/live/runtime.ts +18 -2
  51. package/src/redirect-middleware.ts +3 -0
  52. package/src/redirects.ts +32 -1
  53. package/src/relink.ts +40 -9
  54. package/src/serialize.ts +1 -1
  55. package/src/slug.ts +15 -4
@@ -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,
@@ -46,6 +46,7 @@ import { link, lstat, mkdir, realpath, rm, rmdir, stat, unlink, writeFile } from
46
46
  import { basename, dirname, join, relative, sep } from 'node:path';
47
47
  import type { ResolvedCollection, ResolvedSite } from '../../config.ts';
48
48
  import { DocumentError, documentUrl, resolveDocumentFile, type DocumentEntry } from '../../content.ts';
49
+ import { heldBases, holdsMessage, withHeldBases } from '../../held.ts';
49
50
  import { own, parseAnyDocument, type AnyDocument } from '../../document.ts';
50
51
  import type { Person } from '../../live/messages.ts';
51
52
  import { applyDocumentEdits } from '../../serialize.ts';
@@ -197,10 +198,15 @@ function firstText(site: Site, collection: ResolvedCollection, title: string, bl
197
198
  * Check a create against the rules and the store's listing (`documents`: every
198
199
  * document in every collection now), and work out the new file. Throws WriteRefused.
199
200
  */
200
- export function planCreate(site: Site, request: CreateRequest, documents: readonly DocumentEntry[]): CreatePlan {
201
+ export function planCreate(configured: Site, request: CreateRequest, documents: readonly DocumentEntry[]): CreatePlan {
202
+ // Collections under pages (Decision 7): a held collection's base is resolved from the listing.
203
+ const bases = heldBases(configured.collections, documents);
204
+ const site = withHeldBases(configured, bases);
201
205
  const collection = own(site.collections, request.collection);
202
206
  if (!collection) throw refused(400, 'bad-path', `No collection "${printable(request.collection)}" is declared.`);
203
207
  if (collection.kind === 'settings') throw refused(400, 'not-allowed', 'Site Settings is always there, so it\'s never created.');
208
+ const unheld = own(bases, collection.name)?.problem;
209
+ if (unheld) throw refused(400, 'not-held', unheld);
204
210
  if (request.path === '') throw refused(400, 'bad-name', NO_ADDRESS_MESSAGE);
205
211
  if (request.path.split('/').includes(RESERVED_SLUG)) throw refused(400, 'index', INDEX_MESSAGE);
206
212
 
@@ -227,7 +233,7 @@ export function planCreate(site: Site, request: CreateRequest, documents: readon
227
233
  throw refused(409, 'exists', url ? `There's already a page at ${url}.` : `There's already a ${collection.kind === 'shared' ? 'shared block' : 'document'} called "${where.path}".`);
228
234
  }
229
235
  if (url !== null) {
230
- const collections = Object.values(site.collections).map(({ name, label, urlBase }) => ({ name, label, urlBase }));
236
+ const collections = Object.values(site.collections).map(({ name, label, urlBase, heldBy }) => ({ name, label, urlBase, heldBy }));
231
237
  const problem = addressProblem(url, collection.name, collections, documents, site.codeRoutes);
232
238
  if (problem) throw refused(problem.code === 'address-taken' ? 409 : 400, problem.code, problem.message);
233
239
  }
@@ -252,6 +258,11 @@ export function planDelete(site: Site, request: DeleteRequest, documents: readon
252
258
  throw refused(400, 'not-allowed', collection.urlBase === '/' ? 'Home can\'t be deleted.' : `${entry?.label ?? 'This page'} is the top of ${collection.label}, so it can't be deleted.`);
253
259
  }
254
260
  if (!entry) throw refused(404, 'not-found', `${collection.label}: no document "${printable(where.path)}".`);
261
+ // Collections under pages (Decision 12): a page that holds a collection stays, as Home does.
262
+ const held = entry.holds ? Object.values(site.collections).find((c) => c.name === entry.holds && c.heldBy === collection.name) : undefined;
263
+ if (held) {
264
+ throw refused(409, 'holds', holdsMessage(entry.label, held.label));
265
+ }
255
266
  if (collection.kind === 'shared' && entry.usedOn?.length) {
256
267
  const usedOn = entry.usedOn.map((u) => ({ ...u, title: documents.find((d) => d.collection === u.collection && d.path === u.path)?.label ?? u.path }));
257
268
  throw refused(
@@ -12,7 +12,7 @@ import { SITE_SETTINGS_FILE, type ResolvedSite } from '../../config.ts';
12
12
  import { diskSource, listDocuments, readDocument, type ContentSource } from '../../content.ts';
13
13
  import type { AnyOpenedDocument, CreateRequest, CreateResponse, DeleteRequest, DeleteResponse, ListedDocument, MoveRequest, MoveResponse, OpenedDocument, ReplaceFileRequest, ReplaceFileResponse } from '../types.ts';
14
14
  import { confineOnDisk, createOnDisk, deleteOnDisk, planCreate, planDelete, target } from './create.ts';
15
- import { moveChanges, moveOnDisk, moveResponse, planMove, settingsWithoutRedirectFrom } from './move.ts';
15
+ import { movesChanges, movesOnDisk, moveResponse, movesUnder, planMoves, settingsWithoutRedirectFrom, type ManyMoveRequest } from './move.ts';
16
16
  import { replaceChanges, replaceResponse } from './replace.ts';
17
17
  import { applyWrite, fileVersion, writeAtomic, type WriteContext, type WriteRequest, type WriteResponse } from './write.ts';
18
18
 
@@ -53,11 +53,19 @@ export interface DraftsStore {
53
53
  /**
54
54
  * Change a document's address (pre-M7 item 8; move.ts has the rules, the same in both
55
55
  * stores): the document moves, every link and reference to it is rewritten, and Site
56
- * Settings gets a redirect from the old address. Local: on disk, the new file first and
57
- * the old one last. Live: every change is an autosave under this tab's locks, all going
58
- * out at the next Publish. Throws WriteRefused.
56
+ * Settings gets a redirect from the old address. Collections under pages (Decision 9):
57
+ * everything under a page moves with it, through `moveMany`. Throws WriteRefused.
59
58
  */
60
59
  move(request: MoveRequest, context?: WriteContext): Promise<MoveResponse>;
60
+ /**
61
+ * The many-move engine (collections under pages, Contracts): every document in the
62
+ * request at its new path, and each held document whose holder's address changes,
63
+ * checked as a whole, then applied all or nothing, each linking file rewritten once and
64
+ * Site Settings written once and last, with every redirect and the caller's own edit.
65
+ * Local: on disk, put back on a failure. Live: every lock taken up front, every change an
66
+ * autosave, all rolled back on a failure, all going out at the next Publish. Throws WriteRefused.
67
+ */
68
+ moveMany(request: ManyMoveRequest, context?: WriteContext): Promise<MoveResponse>;
61
69
  /**
62
70
  * M6c G4 (Decision 9): Replace… on a file. Every link to `from`, in every document, points
63
71
  * at `to` (replace.ts has the rules, the same in both stores). Local: each file written
@@ -71,6 +79,15 @@ type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
71
79
 
72
80
  /** The local store: the content files on disk. */
73
81
  export function localDraftsStore(site: Site): DraftsStore {
82
+ const moveMany = async (request: ManyMoveRequest): Promise<MoveResponse> => {
83
+ for (const m of request.moves) await confineOnDisk(site, target(site, m.collection, m.path).file);
84
+ const documents = await listDocuments(site);
85
+ const plans = planMoves(site, request.moves, documents);
86
+ const read = (file: string) => diskSource.readText(join(site.root, file));
87
+ const changes = await movesChanges(site, plans, documents, read, () => request.redirect !== false, request.editSettings);
88
+ await movesOnDisk(site, changes);
89
+ return moveResponse(plans, changes);
90
+ };
74
91
  return {
75
92
  async list() {
76
93
  const entries = await listDocuments(site);
@@ -108,12 +125,10 @@ export function localDraftsStore(site: Site): DraftsStore {
108
125
  async move(request) {
109
126
  // The file first (a link out of the collection is refused before anything else), then the rules.
110
127
  await confineOnDisk(site, target(site, request.collection, request.path).file);
111
- const documents = await listDocuments(site);
112
- const plan = planMove(site, request, documents);
113
- const changes = await moveChanges(site, plan, documents, (file) => diskSource.readText(join(site.root, file)), true);
114
- await moveOnDisk(site, plan, changes);
115
- return moveResponse(site, plan, changes);
128
+ const moves = movesUnder(site, request, await listDocuments(site));
129
+ return moveMany({ moves, ...(request.redirect === false ? { redirect: false } : {}) });
116
130
  },
131
+ moveMany,
117
132
  async replaceFile(request) {
118
133
  const changes = await replaceChanges(site, request, await listDocuments(site), (file) => diskSource.readText(join(site.root, file)));
119
134
  // As a local move writes its relinked files: each confined and atomic, in listing order.