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
@@ -18,41 +18,46 @@
18
18
  * it's free in every collection (the document's own address aside), not under
19
19
  * `/admin` or `/_caracms`, not strictly below another collection's `urlBase`. The
20
20
  * same address is refused (`same`): there's nothing to change.
21
- * - **Pages under it stay where they are** (as on a delete, M5a Decision 12): moving
22
- * Services moves Services' own page, and its pages keep their addresses, as a group.
21
+ * - **Everything under it moves with it** (collections under pages, Decision 9; a delete
22
+ * still leaves them, M5a Decision 12): moving Services to /offerings carries
23
+ * /services/branding to /offerings/branding, and a page that holds a collection carries
24
+ * its documents' addresses (`movesUnder`, `planMoves`). The whole plan is checked, then
25
+ * lands all or nothing: the many-move engine (Contracts), which the arrange screen reuses.
23
26
  *
24
- * What a move changes (`moveChanges`), worked out from the store's own texts:
27
+ * What a move changes (`movesChanges`), worked out from the store's own texts, each file once for every move:
25
28
  *
26
29
  * - the document, at its new path, with its own links to itself rewritten;
27
30
  * - **every other document that links to it or references it** (relink.ts), Site
28
31
  * Settings included, each changed only where it points at the old address;
29
32
  * - **Site Settings' redirects** (redirects.ts): entries to the old address go to the
30
33
  * new one (chains collapse), an entry from the new address goes (a page is there
31
- * now), and `old → new` is added, unless the document was never live (`addRedirect`
32
- * false: live mode's document made since the last Publish).
34
+ * now), and `old → new` is added, unless the request says not to (`redirect: false`,
35
+ * Change address…'s box unticked: pages shuffled before a site launches need none)
36
+ * or the document was never live (live mode's document made since the last Publish).
37
+ * Either way `addRedirect` is false; the chains still collapse.
33
38
  *
34
- * On disk the order is: the new file (an exclusive create), the rewritten files, Site
35
- * Settings, then the old file goes. A failure part way leaves the old document where
36
- * it was, never no document.
39
+ * On disk the order is: the new files (exclusive creates), the rewritten files, Site
40
+ * Settings, then the old files go; a failure part way puts every file back (`movesOnDisk`).
37
41
  */
38
- import { writeFile } from 'node:fs/promises';
42
+ import { readFile, rm, writeFile } from 'node:fs/promises';
39
43
  import { join } from 'node:path';
40
44
  import { SITE_SETTINGS_FILE, type ResolvedCollection, type ResolvedSite } from '../../config.ts';
41
45
  import { documentUrl, type DocumentEntry } from '../../content.ts';
46
+ import { heldBases, withHeldBases, type HeldBase } from '../../held.ts';
42
47
  import { own, parseAnyDocument, type SettingsDocument } from '../../document.ts';
43
48
  import type { Person } from '../../live/messages.ts';
44
- import { redirectsAfterMove, type Redirect } from '../../redirects.ts';
49
+ import { redirectsAddedBy, redirectsAfterMoves, type Redirect } from '../../redirects.ts';
45
50
  import { relinkText, type AddressMove } from '../../relink.ts';
46
51
  import { schemaRef, stampSchema } from '../../schema-key.ts';
47
52
  import { applyKeyedEdits } from '../../serialize.ts';
48
- import { addressProblem, INDEX_MESSAGE, isSlug, NO_ADDRESS_MESSAGE, RESERVED_SLUG, slugify } from '../../slug.ts';
53
+ import { addressProblem, INDEX_MESSAGE, isSlug, NO_ADDRESS_MESSAGE, RESERVED_SLUG, sameAddress, slugify } from '../../slug.ts';
49
54
  import { RELEASE_HEADER, type MoveRequest, type MoveResponse } from '../types.ts';
50
55
  import { confineOnDisk, createOnDisk, deleteOnDisk, MAX_CREATE_BODY_BYTES, refused, target, type Target } from './create.ts';
51
56
  import { checkOrigin, seqOf, tabOf, WriteRefused, writeAtomic, type WriteContext } from './write.ts';
52
57
 
53
58
  type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks' | 'codeRoutes'>;
54
59
 
55
- const MOVE_SHAPE = '{ collection, path, to }';
60
+ const MOVE_SHAPE = '{ collection, path, to, redirect? }';
56
61
 
57
62
  /** A path for a message: control characters shown escaped, long ones cut. */
58
63
  const printable = (value: string) => {
@@ -64,26 +69,54 @@ const printable = (value: string) => {
64
69
  export function parseMoveBody(body: unknown): MoveRequest {
65
70
  if (!body || typeof body !== 'object' || Array.isArray(body)) throw refused(400, 'bad-request', `Malformed body: it must be a JSON object. Send ${MOVE_SHAPE}.`);
66
71
  const b = body as Record<string, unknown>;
67
- const extra = Object.keys(b).filter((k) => !['collection', 'path', 'to'].includes(k));
72
+ const extra = Object.keys(b).filter((k) => !['collection', 'path', 'to', 'redirect'].includes(k));
68
73
  if (extra.length) throw refused(400, 'bad-request', `Malformed body: unexpected ${extra.map((k) => `"${printable(k)}"`).join(', ')}. Send ${MOVE_SHAPE}.`);
69
74
  if (typeof b.collection !== 'string' || b.collection === '') throw refused(400, 'bad-request', `Malformed body: "collection" must be a collection's name. Send ${MOVE_SHAPE}.`);
70
75
  if (typeof b.path !== 'string' || b.path === '') throw refused(400, 'bad-request', `Malformed body: "path" must be the document's path in its collection. Send ${MOVE_SHAPE}.`);
71
76
  if (typeof b.to !== 'string') throw refused(400, 'bad-request', `Malformed body: "to" must be the document's new path in its collection. Send ${MOVE_SHAPE}.`);
77
+ if (b.redirect !== undefined && typeof b.redirect !== 'boolean') throw refused(400, 'bad-request', `Malformed body: "redirect" must be true or false. Send ${MOVE_SHAPE}.`);
72
78
  if (b.path.length > 512 || b.to.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
73
- return { collection: b.collection, path: b.path, to: b.to };
79
+ return { collection: b.collection, path: b.path, to: b.to, ...(b.redirect === false ? { redirect: false } : {}) };
74
80
  }
75
81
 
82
+ /** One document's new path in its collection: the many-move engine's unit (Contracts). */
83
+ export interface PathMove {
84
+ collection: string;
85
+ path: string;
86
+ to: string;
87
+ }
88
+
89
+ /**
90
+ * The many-move engine's request (collections under pages, Decision 9 and Contracts):
91
+ * every document whose path changes, any collections, any number; the one redirect
92
+ * choice; and an optional change to Site Settings' text that lands, or rolls back, with
93
+ * the redirects (the arrange screen's saved order). A held document whose holder moves
94
+ * keeps its path, so it's never listed: the plan adds it.
95
+ */
96
+ export interface ManyMoveRequest {
97
+ moves: readonly PathMove[];
98
+ /** Whether to leave redirects from the old addresses (default true). A document that was never live gets none either way. */
99
+ redirect?: boolean;
100
+ /** A pure function, Site Settings' text → its text, applied after the redirects are folded in. */
101
+ editSettings?: (text: string) => string;
102
+ }
103
+
104
+ /** One address that changes. */
76
105
  export interface MovePlan {
77
106
  from: Target & { entry: DocumentEntry; url: string };
78
107
  to: Target & { url: string };
79
108
  move: AddressMove;
80
109
  title: string;
110
+ /** False for a held document whose holder moved: its file stays where it is, only its address changes. */
111
+ refiled: boolean;
81
112
  }
82
113
 
83
- /** Check a move against the rules and the store's listing (every document, every collection). Throws WriteRefused. */
84
- export function planMove(site: Site, request: MoveRequest, documents: readonly DocumentEntry[]): MovePlan {
114
+ /** The individual rules for one requested move, against the listing as it is. Throws WriteRefused. */
115
+ function checkOne(site: Site, bases: Readonly<Record<string, HeldBase>>, request: PathMove, documents: readonly DocumentEntry[]) {
85
116
  const collection = own(site.collections, request.collection);
86
117
  if (!collection) throw refused(400, 'bad-path', `No collection "${printable(request.collection)}" is declared.`);
118
+ const unheld = own(bases, collection.name)?.problem;
119
+ if (unheld) throw refused(400, 'not-held', unheld);
87
120
  if (collection.urlBase === null) {
88
121
  const what = collection.kind === 'shared' ? 'A shared block' : collection.kind === 'settings' ? 'Site Settings' : `${collection.label} has no pages of its own, so a document there`;
89
122
  throw refused(400, 'not-allowed', `${what} has no address to change.`);
@@ -110,40 +143,142 @@ export function planMove(site: Site, request: MoveRequest, documents: readonly D
110
143
  const better = slugify(part);
111
144
  throw refused(400, 'bad-name', better ? `"${printable(part)}" can't be part of an address; "${better}" can.` : NO_ADDRESS_MESSAGE);
112
145
  });
113
- const toUrl = documentUrl(collection, to.path)!;
114
- if (documents.some((d) => d.file === to.file)) throw refused(409, 'address-taken', `There's already a page at ${toUrl}.`);
115
- const others = documents.filter((d) => d.file !== from.file);
116
- const collections = Object.values(site.collections).map(({ name, label, urlBase }) => ({ name, label, urlBase }));
117
- const problem = addressProblem(toUrl, collection.name, collections, others, site.codeRoutes);
118
- if (problem) throw refused(problem.code === 'address-taken' ? 409 : 400, problem.code, problem.message);
119
- return {
120
- from: { ...from, entry, url: fromUrl },
121
- to: { ...to, url: toUrl },
122
- move: { collection: collection.name, fromPath: from.path, toPath: to.path, fromUrl, toUrl },
123
- title: entry.label,
124
- };
146
+ return { collection, from: { ...from, entry, url: fromUrl }, to };
147
+ }
148
+
149
+ /**
150
+ * Change address… on a page (Decision 9): the page, then every page under it at any
151
+ * depth, each at the same place under the new address. (Held documents follow their
152
+ * holder in `planMoves`.) Anything else is the one move asked for.
153
+ */
154
+ export function movesUnder(site: Pick<Site, 'collections'>, request: Pick<PathMove, 'collection' | 'path' | 'to'>, documents: readonly DocumentEntry[]): PathMove[] {
155
+ const root: PathMove = { collection: request.collection, path: request.path, to: request.to };
156
+ const collection = own(site.collections, request.collection);
157
+ if (!collection?.nested || request.to === request.path) return [root];
158
+ if (request.to.startsWith(`${request.path}/`)) {
159
+ const label = documents.find((d) => d.collection === collection.name && d.path === request.path)?.label ?? request.path;
160
+ throw refused(400, 'not-allowed', `${label} can't go under itself.`);
161
+ }
162
+ const under = documents.filter((d) => d.collection === collection.name && d.path.startsWith(`${request.path}/`));
163
+ // A file under it that can't be opened has no address to carry: it would be left behind as a group.
164
+ const broken = under.find((d) => d.warning);
165
+ if (broken) {
166
+ const label = documents.find((d) => d.collection === collection.name && d.path === request.path)?.label ?? request.path;
167
+ throw refused(400, 'not-allowed', `${broken.file} is under ${label} and can't be opened, so ${label} can't move with everything under it. Fix or delete that file first.`);
168
+ }
169
+ return [root, ...under.map((d) => ({ collection: d.collection, path: d.path, to: `${request.to}${d.path.slice(request.path.length)}` }))];
170
+ }
171
+
172
+ /**
173
+ * Check a whole plan against the rules and the store's listing at call time (every
174
+ * document, every collection), and work out every address that changes: the requested
175
+ * moves, in order, then the documents of each held collection whose holder's address
176
+ * changes. Each requested move follows a single move's rules; then, as a whole, every new
177
+ * address must be free of documents (as they'll be once the plan has landed, so a swap is
178
+ * fine) and code routes, none reserved, and no two moves may land on one address.
179
+ * Throws WriteRefused, naming the first problem.
180
+ */
181
+ export function planMoves(configured: Site, requests: readonly PathMove[], documents: readonly DocumentEntry[]): MovePlan[] {
182
+ if (requests.length === 0) throw refused(400, 'bad-request', 'Nothing to move.');
183
+ // Collections under pages (Decision 7): a held collection's base is resolved from the listing.
184
+ const before = heldBases(configured.collections, documents);
185
+ const site = withHeldBases(configured, before);
186
+ const checked = requests.map((r) => checkOne(site, before, r, documents));
187
+ const moving = new Map<string, (typeof checked)[number]>();
188
+ for (const c of checked) {
189
+ if (moving.has(c.from.file)) throw refused(400, 'bad-request', `${c.from.entry.label} is in the plan twice.`);
190
+ moving.set(c.from.file, c);
191
+ }
192
+
193
+ // The listing once the plan has landed: moved documents at their new paths, then every
194
+ // held collection's base worked out again from it (a holder may have moved).
195
+ const shifted = documents.map((d) => {
196
+ const c = moving.get(d.file);
197
+ return c ? { ...d, path: c.to.path, file: c.to.file, url: documentUrl(site.collections[d.collection]!, c.to.path) } : d;
198
+ });
199
+ const after = heldBases(configured.collections, shifted);
200
+ const landed = withHeldBases(configured, after);
201
+ const urlAfter = (d: { collection: string; path: string }) => documentUrl(landed.collections[d.collection]!, d.path);
202
+
203
+ const plans: MovePlan[] = checked.map((c) => {
204
+ const toUrl = urlAfter(c.to);
205
+ if (toUrl === null) throw refused(400, 'not-held', own(after, c.collection.name)?.problem ?? `${c.from.entry.label} would have no address.`);
206
+ return {
207
+ from: c.from,
208
+ to: { ...c.to, url: toUrl },
209
+ move: { collection: c.collection.name, fromPath: c.from.path, toPath: c.to.path, fromUrl: c.from.url, toUrl },
210
+ title: c.from.entry.label,
211
+ refiled: true,
212
+ };
213
+ });
214
+ // Each held collection whose holder's address changes: its documents follow, each an ordinary move (Decision 3).
215
+ for (const b of Object.values(before)) {
216
+ const next = after[b.collection];
217
+ if (b.base === null || !next?.base || next.base === b.base) continue;
218
+ for (const d of documents) {
219
+ if (d.collection !== b.collection || d.warning || moving.has(d.file)) continue;
220
+ const fromUrl = documentUrl(site.collections[d.collection]!, d.path);
221
+ const toUrl = urlAfter(d);
222
+ if (fromUrl === null || toUrl === null) continue;
223
+ const at = target(site, d.collection, d.path);
224
+ plans.push({ from: { ...at, entry: d, url: fromUrl }, to: { ...at, url: toUrl }, move: { collection: d.collection, fromPath: d.path, toPath: d.path, fromUrl, toUrl }, title: d.label, refiled: false });
225
+ }
226
+ }
227
+
228
+ // As a whole: every new address free, none reserved, no two moves to one.
229
+ const lead = (p: MovePlan, i: number) => (i === 0 ? '' : `${p.title} would move to ${p.to.url}. `);
230
+ const listedAfter = shifted.map((d) => ({ ...d, url: urlAfter(d) }));
231
+ const collections = Object.values(landed.collections).map(({ name, label, urlBase, heldBy }) => ({ name, label, urlBase, heldBy }));
232
+ const vacated = new Set(plans.filter((p) => p.refiled).map((p) => p.from.file));
233
+ const seen = new Map<string, MovePlan>();
234
+ plans.forEach((p, i) => {
235
+ const key = sameAddress(p.to.url);
236
+ const twice = seen.get(key);
237
+ if (twice) throw refused(409, 'address-taken', `${twice.title} and ${p.title} would both be at ${p.to.url}.`);
238
+ seen.set(key, p);
239
+ if (p.refiled && !vacated.has(p.to.file) && documents.some((d) => d.file === p.to.file)) throw refused(409, 'address-taken', `${lead(p, i)}There's already a page at ${p.to.url}.`);
240
+ const others = listedAfter.filter((d) => d.file !== p.to.file);
241
+ const problem = addressProblem(p.to.url, p.move.collection, collections, others, site.codeRoutes);
242
+ if (problem) throw refused(problem.code === 'address-taken' ? 409 : 400, problem.code, `${lead(p, i)}${problem.message}`);
243
+ });
244
+ return plans;
245
+ }
246
+
247
+ /** One move's plan (a page alone, with nothing under it). See `planMoves`. */
248
+ export function planMove(site: Site, request: PathMove, documents: readonly DocumentEntry[]): MovePlan {
249
+ return planMoves(site, [request], documents)[0]!;
125
250
  }
126
251
 
127
252
  export interface MoveChanges {
128
- /** The document's text at its new path. */
129
- moved: string;
130
- /** Every other document whose text changes (links or references to it), Site Settings aside. */
253
+ /** Each refiled document's text at its new path, in plan order. */
254
+ moved: { plan: MovePlan; text: string }[];
255
+ /** Every other document whose text changes (links or references to any of them), Site Settings aside: a held document whose file stays among them. */
131
256
  relinked: { file: string; collection: string; path: string; title: string; text: string }[];
132
257
  /** Site Settings' text after (null: no change), and before (null: the file isn't there yet). */
133
258
  settings: { before: string | null; after: string } | null;
134
- redirect: Redirect | null;
259
+ /** The redirects added, one per address that was live (and asked for). */
260
+ redirects: Redirect[];
261
+ /** Whether Site Settings changed for its own links, not only its redirects. */
262
+ settingsRelinked: boolean;
135
263
  }
136
264
 
137
265
  const settingsCollection = (site: Site): ResolvedCollection | undefined => Object.values(site.collections).find((c) => c.kind === 'settings');
138
266
 
139
267
  /**
140
- * Site Settings' text once its redirects follow the move (and its own links are
141
- * rewritten), or null when nothing changes. `text` null: there's no Site Settings file yet.
268
+ * Site Settings' text once its redirects follow every move (redirects.ts →
269
+ * `redirectsAfterMoves`: all at once, against the list before the plan, so a swap or a
270
+ * chain comes out right; `add` per move), its own links are rewritten, and the caller's own
271
+ * `edit` is applied; null when nothing changes. `text` null: there's no Site Settings file yet.
142
272
  */
143
- export function settingsAfterMove(site: Site, text: string | null, move: AddressMove, addRedirect: boolean): string | null {
273
+ export function settingsAfterMoves(
274
+ site: Site,
275
+ text: string | null,
276
+ moves: readonly { move: AddressMove; add: boolean }[],
277
+ edit?: (text: string) => string,
278
+ ): string | null {
144
279
  const collection = settingsCollection(site);
145
280
  if (!collection) return null;
146
- const relinked = text === null ? null : relinkText(text, move, site, collection).text;
281
+ const relinked = text === null ? null : relinkText(text, moves.map((m) => m.move), site, collection).text;
147
282
  let raw: unknown = {};
148
283
  if (relinked !== null) {
149
284
  try {
@@ -153,12 +288,18 @@ export function settingsAfterMove(site: Site, text: string | null, move: Address
153
288
  }
154
289
  }
155
290
  const doc = parseAnyDocument(raw, site, collection) as SettingsDocument;
156
- const redirects = redirectsAfterMove(doc.data.redirects ?? [], move.fromUrl, move.toUrl, addRedirect);
291
+ const redirects = redirectsAfterMoves(doc.data.redirects ?? [], moves.map((m) => ({ from: m.move.fromUrl, to: m.move.toUrl, add: m.add })));
157
292
  const title = relinked === null ? 'Site Settings' : doc.title;
158
- const after = applyKeyedEdits(relinked, { title, data: { ...doc.data, redirects } }, collection);
293
+ let after = applyKeyedEdits(relinked, { title, data: { ...doc.data, redirects } }, collection);
294
+ if (edit) after = edit(after);
159
295
  return after === text ? null : after;
160
296
  }
161
297
 
298
+ /** One move's Site Settings (see `settingsAfterMoves`). */
299
+ export function settingsAfterMove(site: Site, text: string | null, move: AddressMove, addRedirect: boolean): string | null {
300
+ return settingsAfterMoves(site, text, [{ move, add: addRedirect }]);
301
+ }
302
+
162
303
  /**
163
304
  * Site Settings' text without its redirect from `url`, or null when it has none: a new
164
305
  * document made at an address that redirected (a page is there again). Both stores'
@@ -180,79 +321,123 @@ export function settingsWithoutRedirectFrom(site: Site, text: string | null, url
180
321
  }
181
322
 
182
323
  /**
183
- * What the move changes, from the store's own texts (`readText` by site-relative file;
184
- * live mode's has the autosaves on top). `addRedirect`: whether `old → new` is added.
324
+ * What the plan changes, from the store's own texts (`readText` by site-relative file;
325
+ * live mode's has the autosaves on top): each linking file rewritten once for all the
326
+ * moves that touch it. `addRedirect(plan)`: whether that address gets `old → new`.
185
327
  */
186
- export async function moveChanges(
328
+ export async function movesChanges(
187
329
  site: Site,
188
- plan: MovePlan,
330
+ plans: readonly MovePlan[],
189
331
  documents: readonly DocumentEntry[],
190
332
  readText: (file: string) => Promise<string | null>,
191
- addRedirect: boolean,
333
+ addRedirect: (plan: MovePlan) => boolean,
334
+ editSettings?: (text: string) => string,
192
335
  ): Promise<MoveChanges> {
193
- const text = await readText(plan.from.file);
194
- if (text === null) throw refused(404, 'not-found', `${site.collections[plan.from.collection]!.label}: no document "${plan.from.path}".`);
336
+ const moves = plans.map((p) => p.move);
195
337
  const collectionOf = (name: string) => site.collections[name]!;
196
- // M8 Decision 14: its `$schema` path follows the file to its new folder.
197
- const moved = stampSchema(relinkText(text, plan.move, site, collectionOf(plan.from.collection)).text, schemaRef(collectionOf(plan.to.collection), plan.to.file), { add: false });
338
+ const refiled = new Set(plans.filter((p) => p.refiled).map((p) => p.from.file));
339
+ const moved: MoveChanges['moved'] = [];
340
+ for (const plan of plans) {
341
+ if (!plan.refiled) continue;
342
+ const text = await readText(plan.from.file);
343
+ if (text === null) throw refused(404, 'not-found', `${collectionOf(plan.from.collection).label}: no document "${plan.from.path}".`);
344
+ // M8 Decision 14: its `$schema` path follows the file to its new folder.
345
+ moved.push({ plan, text: stampSchema(relinkText(text, moves, site, collectionOf(plan.from.collection)).text, schemaRef(collectionOf(plan.to.collection), plan.to.file), { add: false }) });
346
+ }
198
347
  const relinked: MoveChanges['relinked'] = [];
199
348
  for (const d of documents) {
200
- if (d.file === plan.from.file || d.file === SITE_SETTINGS_FILE) continue;
349
+ if (refiled.has(d.file) || d.file === SITE_SETTINGS_FILE) continue;
201
350
  const text = await readText(d.file);
202
351
  if (text === null) continue;
203
- const after = relinkText(text, plan.move, site, collectionOf(d.collection));
352
+ const after = relinkText(text, moves, site, collectionOf(d.collection));
204
353
  if (after.links) relinked.push({ file: d.file, collection: d.collection, path: d.path, title: d.label, text: after.text });
205
354
  }
206
355
  const before = await readText(SITE_SETTINGS_FILE);
207
- const after = settingsAfterMove(site, before, plan.move, addRedirect);
356
+ const adds = plans.map((p) => ({ move: p.move, add: addRedirect(p) }));
357
+ const after = settingsAfterMoves(site, before, adds, editSettings);
358
+ const settings = settingsCollection(site);
208
359
  return {
209
360
  moved,
210
361
  relinked,
211
362
  settings: after === null ? null : { before, after },
212
- redirect: addRedirect ? { from: plan.move.fromUrl, to: plan.move.toUrl } : null,
363
+ redirects: redirectsAddedBy(adds.map((a) => ({ from: a.move.fromUrl, to: a.move.toUrl, add: a.add }))),
364
+ settingsRelinked: !!settings && before !== null && relinkText(before, moves, site, settings).links > 0,
213
365
  };
214
366
  }
215
367
 
216
- /** The route's answer for a move done. */
217
- export function moveResponse(site: Site, plan: MovePlan, changes: MoveChanges): MoveResponse {
368
+ /** The route's answer for a plan done: the first plan's document is the one asked about. */
369
+ export function moveResponse(plans: readonly MovePlan[], changes: MoveChanges): MoveResponse {
370
+ const [first] = plans;
218
371
  const relinked = changes.relinked.map(({ collection, path, title }) => ({ collection, path, title }));
219
- if (changes.settings && relinkTouched(site, changes.settings, plan.move)) relinked.push({ collection: 'settings', path: 'site-settings', title: 'Site Settings' });
372
+ if (changes.settingsRelinked) relinked.push({ collection: 'settings', path: 'site-settings', title: 'Site Settings' });
373
+ const redirectOf = (p: MovePlan) => changes.redirects.find((r) => r.from === p.move.fromUrl) ?? null;
220
374
  return {
221
375
  ok: true,
222
- collection: plan.to.collection,
223
- path: plan.to.path,
224
- file: plan.to.file,
225
- url: plan.to.url,
226
- from: { path: plan.from.path, file: plan.from.file, url: plan.from.url },
376
+ collection: first!.to.collection,
377
+ path: first!.to.path,
378
+ file: first!.to.file,
379
+ url: first!.to.url,
380
+ from: { path: first!.from.path, file: first!.from.file, url: first!.from.url },
227
381
  relinked,
228
- redirect: changes.redirect,
382
+ redirect: redirectOf(first!),
383
+ moved: plans.map((p) => ({ collection: p.move.collection, from: p.move.fromPath, to: p.move.toPath, title: p.title, fromUrl: p.move.fromUrl, toUrl: p.move.toUrl })),
384
+ redirects: changes.redirects,
229
385
  };
230
386
  }
231
387
 
232
- /** Whether Site Settings changed for its own links (not only its redirects). */
233
- function relinkTouched(site: Site, settings: { before: string | null }, move: AddressMove): boolean {
234
- const collection = settingsCollection(site);
235
- return !!collection && settings.before !== null && relinkText(settings.before, move, site, collection).links > 0;
236
- }
237
-
238
388
  // ── The disk (local mode) ──────────────────────────────────────────────────
239
389
 
240
- /** Move on disk: the new file (exclusive), the rewritten files, Site Settings, then the old file goes. */
241
- export async function moveOnDisk(site: Site, plan: MovePlan, changes: MoveChanges): Promise<void> {
242
- const collection = site.collections[plan.to.collection]!;
243
- const document = parseAnyDocument(JSON.parse(changes.moved), site, collection);
244
- await createOnDisk(site, { ...plan.to, title: plan.title, text: changes.moved, document });
245
- for (const r of changes.relinked) {
246
- const confined = await confineOnDisk(site, r.file);
247
- await writeAtomic(confined.absolute, r.text);
248
- }
249
- if (changes.settings) {
250
- const file = join(site.root, SITE_SETTINGS_FILE);
251
- if (changes.settings.before === null) {
252
- await writeFile(file, changes.settings.after, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
253
- } else await writeAtomic(file, changes.settings.after);
390
+ /**
391
+ * The plan on disk, all or nothing: each new file (an exclusive create; a file another
392
+ * move in the plan vacates is written over, as a swap needs), the rewritten files, Site
393
+ * Settings, then the old files go (and the folders they leave empty). Every text was read
394
+ * first, so a failure part way puts every file back as it was.
395
+ */
396
+ export async function movesOnDisk(site: Site, changes: MoveChanges): Promise<void> {
397
+ const vacated = new Set(changes.moved.map((m) => m.plan.from.file));
398
+ const targets = new Set(changes.moved.map((m) => m.plan.to.file));
399
+ const undo: (() => Promise<unknown>)[] = [];
400
+ const overwrite = async (file: string, text: string, before: string | null) => {
401
+ const confined = await confineOnDisk(site, file);
402
+ const prior = before ?? (await readFile(confined.absolute, 'utf8'));
403
+ await writeAtomic(confined.absolute, text);
404
+ undo.push(() => writeAtomic(confined.absolute, prior));
405
+ };
406
+ try {
407
+ for (const { plan, text } of changes.moved) {
408
+ if (vacated.has(plan.to.file)) {
409
+ await overwrite(plan.to.file, text, null);
410
+ continue;
411
+ }
412
+ const collection = site.collections[plan.to.collection]!;
413
+ const document = parseAnyDocument(JSON.parse(text), site, collection);
414
+ await createOnDisk(site, { ...plan.to, title: plan.title, text, document });
415
+ undo.push(() => deleteOnDisk(site, { ...plan.to, entry: plan.from.entry }));
416
+ }
417
+ for (const r of changes.relinked) await overwrite(r.file, r.text, null);
418
+ if (changes.settings) {
419
+ const file = join(site.root, SITE_SETTINGS_FILE);
420
+ if (changes.settings.before === null) {
421
+ await writeFile(file, changes.settings.after, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
422
+ undo.push(() => rm(file, { force: true }));
423
+ } else {
424
+ await writeAtomic(file, changes.settings.after);
425
+ const before = changes.settings.before;
426
+ undo.push(() => writeAtomic(file, before));
427
+ }
428
+ }
429
+ for (const { plan } of changes.moved) {
430
+ if (targets.has(plan.from.file)) continue;
431
+ const confined = await confineOnDisk(site, plan.from.file);
432
+ const text = await readFile(confined.absolute, 'utf8');
433
+ await deleteOnDisk(site, { ...plan.from, entry: plan.from.entry });
434
+ const collection = site.collections[plan.from.collection]!;
435
+ undo.push(() => createOnDisk(site, { ...plan.from, title: plan.title, text, document: parseAnyDocument(JSON.parse(text), site, collection) }));
436
+ }
437
+ } catch (error) {
438
+ for (const step of undo.reverse()) await step().catch(() => {});
439
+ throw error;
254
440
  }
255
- await deleteOnDisk(site, { ...plan.from, entry: plan.from.entry });
256
441
  }
257
442
 
258
443
  // ── The route ──────────────────────────────────────────────────────────────
@@ -228,6 +228,8 @@ details.fgroup>summary .wmark{margin-left:var(--space-1)}
228
228
  details.fgroup>summary .wmark .i{margin-left:0;rotate:none}
229
229
  /* M9 Decision 7: the page's "Hidden from the site" switch, between its page fields and SEO. */
230
230
  .page-hidden{margin-top:var(--space-4)}
231
+ /* Collections under pages (Decision 5): "Holds Journal (24)", beside Hidden, with no control */
232
+ .page-holds{display:flex;align-items:center;gap:var(--space-2);margin:var(--space-2) 0 0;font-size:var(--text-sm);color:var(--text-muted)}
231
233
  /* A group field (group()): Page details' section, among a form's fields; inside a list item it keeps to the item's width. */
232
234
  details.fgroup.group-field{margin-block:var(--space-3)}
233
235
  details.fgroup.group-field+details.fgroup.group-field{margin-top:calc(-1 * var(--space-3))}
@@ -462,6 +464,9 @@ details.fgroup.group-field+details.fgroup.group-field{margin-top:calc(-1 * var(-
462
464
  .dialog>p,.dialog>form>p{color:var(--text-muted)}
463
465
  .dialog>p+p,.dialog>form>p+p{margin-top:var(--space-2)}
464
466
  .dialog .field{margin:var(--space-4) 0 0}
467
+ .dialog .check{display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text);cursor:pointer}
468
+ .dialog .check input{flex:none;width:16px;height:16px;margin:0;accent-color:var(--select);cursor:inherit}
469
+ .dialog .check + .field-help{margin-top:var(--space-1);padding-left:calc(16px + var(--space-2))}
465
470
  .dialog .acts{display:flex;justify-content:flex-end;gap:var(--space-2);margin-top:var(--space-6)}
466
471
  .dialog .icon-h{display:grid;place-items:center;width:36px;height:36px;margin-bottom:var(--space-3);border-radius:50%}
467
472
  .urlpre{display:flex;align-items:center;gap:var(--space-1);margin-top:var(--space-1-5);font-size:var(--text-sm);color:var(--text-muted)}
@@ -37,6 +37,8 @@
37
37
  .ct-open:focus-visible{outline:2px solid var(--select);outline-offset:2px;border-radius:var(--radius-xs)}
38
38
  .ct-title-cell .meta{display:inline-flex;align-items:center;gap:var(--space-1);margin-left:var(--space-1-5)}
39
39
  .ct-title-cell .meta:empty{display:none}
40
+ /* Hidden (M9 Decision 7; collections under pages, Decision 11): the sidebar row's mark, small and grey, not part of the title. */
41
+ .ct-title-cell .hmark{font-size:var(--text-xs);font-weight:var(--weight-regular);color:var(--text-subtle);white-space:nowrap}
40
42
  .ct-extra{margin-left:var(--space-2);font-size:var(--text-xs);color:var(--text-subtle)}
41
43
  .ct tr.is-warned .ct-open{color:var(--text-muted)}
42
44
  .ct tr.is-current .ct-open{color:var(--select-strong);font-weight:var(--weight-semibold)}
@@ -11,6 +11,21 @@
11
11
  .ptype.sm .i{color:var(--icon)}
12
12
  .ptype[aria-checked="true"]{border-color:var(--select);background:var(--select-wash)}
13
13
  .ptype[aria-checked="true"] b,.ptype[aria-checked="true"] .i{color:var(--select-strong)}
14
+ /* Collections under pages (Decision 9): Change address…'s list of every address that changes */
15
+ .mv-list{list-style:none;margin:var(--space-2) 0 0;padding:var(--space-1) var(--space-2);max-height:11rem;overflow:auto;border:1px solid var(--border);border-radius:var(--radius-md);font-size:var(--text-sm);line-height:1.6}
16
+ .mv-list li{overflow-wrap:anywhere}
17
+ /* In a short window the dialog stays inside it, the list the part that gives (down to about
18
+ two addresses), so Cancel and Change address always show. A column, not a scrolling dialog:
19
+ the Inside picker's popover must not be clipped. The margins keep the block layout's gaps
20
+ (no margins collapse in a column). */
21
+ .dialog[data-dialog="move"]{display:flex;flex-direction:column;max-height:calc(100dvh - 2 * var(--space-4))}
22
+ .dialog[data-dialog="move"]>form{display:flex;flex-direction:column;min-height:0}
23
+ .dialog[data-dialog="move"]>form>*{flex:none}
24
+ .dialog[data-dialog="move"]>form>h2+.field{margin-top:var(--space-2)}
25
+ .dialog[data-dialog="move"]>form>.field>:last-child{margin-bottom:0}
26
+ .dialog[data-dialog="move"]>form>[data-moving]{flex:0 1 auto;display:flex;flex-direction:column;min-height:0}
27
+ .dialog[data-dialog="move"]>form>[data-moving]>*{flex:none}
28
+ .dialog[data-dialog="move"]>form>[data-moving]>.mv-list{flex:0 1 auto;min-height:3rem;margin-top:0}
14
29
  .dlg-list{display:flex;flex-direction:column;gap:2px;margin-top:var(--space-3);padding:var(--space-1);border:1px solid var(--border);border-radius:var(--radius-md)}
15
30
  /* Site Settings (M5a): a fixed form with no page of its own */
16
31
  .set-sec{margin:0 calc(-1 * var(--space-4));padding:var(--space-4) var(--space-4) var(--space-1);border-top:1px solid var(--border)}
@@ -54,6 +54,16 @@ export interface EditorAnyCollection {
54
54
  sort?: import('../columns.ts').SortConfig | null;
55
55
  /** A section builder's offered blocks (config.ts); null or absent for every block. */
56
56
  blocks?: string[] | null;
57
+ /**
58
+ * Collections under pages (Decision 7): the nested collection whose page holds this one, set
59
+ * only on a held collection. Its `urlBase` is then the server's resolved base (the holder's
60
+ * address + `/`), or null while `problem` says why it has none.
61
+ */
62
+ heldBy?: string | null;
63
+ /** A held collection's holder (held.ts → HeldBase); null or absent when none holds it, and on any other collection. */
64
+ holder?: { collection: string; path: string } | null;
65
+ /** Decision 13's sentence for a held collection with no addresses (shown on its sidebar entry); null or absent otherwise. */
66
+ problem?: string | null;
57
67
  }
58
68
 
59
69
  /**
@@ -486,9 +496,11 @@ export interface StatusResponse {
486
496
  * Runs-anywhere Decision 5: what's wrong with the site's GitHub access, for every
487
497
  * person, while it holds (not dismissable, unlike `notices`): a refused credential, a
488
498
  * missing permission, or a token expiring within 14 days. `message` names the fix,
489
- * for the developer it's forwarded to. Null when nothing is wrong.
499
+ * for the developer it's forwarded to. Null when nothing is wrong. `cloud` (caracms-cloud
500
+ * M2 Decision 14): CaraCMS Cloud refused a token; `message` is the cloud's sentence and
501
+ * `link` the install link it names, if any, which the note shows as a link.
490
502
  */
491
- githubAccess: { kind: 'refused' | 'permission' | 'expiring'; title: string; message: string } | null;
503
+ githubAccess: { kind: 'refused' | 'permission' | 'expiring' | 'cloud'; title: string; message: string; link?: string | null } | null;
492
504
  /**
493
505
  * The brand as committed in Site Settings (M5a Decision 10: read from `drafts`, never
494
506
  * an autosave), so the chrome picks up a change on the next poll with no reload.
@@ -582,12 +594,15 @@ export interface DeleteResponse {
582
594
  * POST /_caracms/move (pre-M7 item 8): change a document's address. `to` is its new
583
595
  * path in the same collection (`studio/visit`), its new parts made with slug.ts as a
584
596
  * new document's are. Every link and reference to it is rewritten, and a redirect from
585
- * the old address to the new is added to Site Settings.
597
+ * the old address to the new is added to Site Settings unless `redirect` is false
598
+ * (Change address…'s "Leave a redirect from …", on unless it's unticked).
586
599
  */
587
600
  export interface MoveRequest {
588
601
  collection: string;
589
602
  path: string;
590
603
  to: string;
604
+ /** Whether to leave a redirect from the old address (default true). A document that was never live gets none either way. */
605
+ redirect?: boolean;
591
606
  }
592
607
 
593
608
  /** 200: the document is at its new path (live mode: as autosaves, all going out at the next Publish). */
@@ -602,8 +617,16 @@ export interface MoveResponse {
602
617
  from: { path: string; file: string; url: string };
603
618
  /** The other documents whose links (or references) to it were rewritten, with their titles. */
604
619
  relinked: { collection: string; path: string; title: string }[];
605
- /** The redirect added: null for a document that was never live (made since the last Publish). */
620
+ /** The redirect added: null when none was asked for, or for a document that was never live (made since the last Publish). */
606
621
  redirect: { from: string; to: string } | null;
622
+ /**
623
+ * Collections under pages (Decision 9): every address that changed, this document's first,
624
+ * then the pages under it and the documents of a collection held under it (`from` = `to`:
625
+ * a held document's file stays where it is).
626
+ */
627
+ moved: { collection: string; from: string; to: string; title: string; fromUrl: string; toUrl: string }[];
628
+ /** Every redirect added, one per address that was live (none when none was asked for). */
629
+ redirects: { from: string; to: string }[];
607
630
  }
608
631
 
609
632
  /**
@@ -613,7 +636,8 @@ export interface MoveResponse {
613
636
  * isn't an address `slugify` leaves as it is, or nothing left), `index`,
614
637
  * `reserved-address` (under `/admin` or `/_caracms`), `below-collection` (strictly
615
638
  * under another collection's address), `not-allowed` (Site Settings, Home, a shared
616
- * block with no or an unknown block type);
639
+ * block with no or an unknown block type), `not-held` (collections under pages, Decision 7: a
640
+ * held collection no page holds, or whose holding is a problem; the message is held.ts's sentence);
617
641
  * - 404 `not-found` (delete: no such document);
618
642
  * - 409 `exists` (create: the document is already there), `address-taken` (another
619
643
  * document has that address: "There's already a page at /services/packaging"),
@@ -636,10 +660,12 @@ export type CreateDeleteCode =
636
660
  | 'reserved-address'
637
661
  | 'below-collection'
638
662
  | 'not-allowed'
663
+ | 'not-held'
639
664
  | 'not-found'
640
665
  | 'exists'
641
666
  | 'address-taken'
642
667
  | 'in-use'
668
+ | 'holds'
643
669
  | 'locked'
644
670
  | 'same';
645
671