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
@@ -120,6 +120,21 @@ export interface Read<T> {
120
120
 
121
121
  /** The Redis hash of moves waiting to publish (old file → new file). */
122
122
  export const movesKey = (prefix: string) => `${prefix}moves`;
123
+ /**
124
+ * Collections under pages: the moves hash also holds each moved document's live address,
125
+ * as `@<file now>` → the address (see `recordAddresses`), so the status poll reads no more
126
+ * keys than before (Redis diet). `splitMoves` parts the two.
127
+ */
128
+ export const ADDRESS_FIELD = '@';
129
+ export function splitMoves(fields: Iterable<[string, string]>): { records: Map<string, string>; addresses: Map<string, string> } {
130
+ const records = new Map<string, string>();
131
+ const addresses = new Map<string, string>();
132
+ for (const [k, v] of fields) {
133
+ if (k.startsWith(ADDRESS_FIELD)) addresses.set(k.slice(ADDRESS_FIELD.length), v);
134
+ else records.set(k, v);
135
+ }
136
+ return { records, addresses };
137
+ }
123
138
 
124
139
  const parse = <T>(raw: string | null): T | null => {
125
140
  if (raw === null) return null;
@@ -243,7 +258,7 @@ export class Autosaves {
243
258
 
244
259
  /** Pre-M7 item 8: the moves waiting to publish, old file → new file. */
245
260
  async moves(): Promise<Map<string, string>> {
246
- return new Map(Object.entries(await this.redis.hgetall(movesKey(this.prefix))));
261
+ return splitMoves(Object.entries(await this.redis.hgetall(movesKey(this.prefix)))).records;
247
262
  }
248
263
 
249
264
  private async setMove(from: string, to: string): Promise<void> {
@@ -271,11 +286,83 @@ export class Autosaves {
271
286
  } else if (wasLive) await this.setMove(from, to);
272
287
  }
273
288
 
289
+ /**
290
+ * Several documents moved at once (the many-move engine), each as `recordMove` would,
291
+ * but every one read against the records before the plan: in a chain (A → B, B → C)
292
+ * the second move's `from` is the first's `to`, and read one at a time it would take
293
+ * over the first's record. In a swap each gets its record (`movesIn` counts neither
294
+ * while both files are there).
295
+ */
296
+ async recordMoves(entries: readonly { from: string; to: string; wasLive: boolean }[]): Promise<void> {
297
+ if (entries.length === 0) return;
298
+ const before = await this.moves();
299
+ const after = new Map(before);
300
+ const origins = entries.map((e) => [...before].find(([, t]) => t === e.from)?.[0] ?? (e.wasLive ? e.from : null));
301
+ for (const origin of origins) if (origin !== null) after.delete(origin);
302
+ entries.forEach((e, i) => {
303
+ const origin = origins[i]!;
304
+ if (origin !== null && origin !== e.to) after.set(origin, e.to);
305
+ });
306
+ const drop = [...before.keys()].filter((f) => !after.has(f));
307
+ if (drop.length) await this.dropMoves(...drop);
308
+ for (const [f, t] of after) if (before.get(f) !== t) await this.setMove(f, t);
309
+ }
310
+
311
+ /** Collections under pages: each moved document's live address, by its file now. */
312
+ async addresses(): Promise<Map<string, string>> {
313
+ return splitMoves(Object.entries(await this.redis.hgetall(movesKey(this.prefix)))).addresses;
314
+ }
315
+
316
+ /**
317
+ * Collections under pages (Decision 9): each address a move changed, in one write. The
318
+ * record is the document's live address, by its file now: a held document keeps its file
319
+ * when its holder moves, so its old address can't be worked out from a file's move, nor
320
+ * from its holder's address now. Moved since the last Publish already: the record follows
321
+ * it to its new file; back at its live address: the record goes. A document that was
322
+ * never live (`wasLive` false, no record) gets none.
323
+ */
324
+ async recordAddresses(entries: readonly { from: string; to: string; toUrl: string; fromUrl: string; wasLive: boolean }[]): Promise<void> {
325
+ if (entries.length === 0) return;
326
+ // Every live address from the records as they were before the plan, never from the
327
+ // map being changed: in a swap or a chain one entry's `to` is another's `from`.
328
+ const before = await this.addresses();
329
+ const same = (a: string, b: string) => a.replace(/\/+$/, '').toLowerCase() === b.replace(/\/+$/, '').toLowerCase();
330
+ const live = entries.map((e) => before.get(e.from) ?? (e.wasLive ? e.fromUrl : null));
331
+ const after = new Map(before);
332
+ for (const e of entries) if (e.from !== e.to) after.delete(e.from);
333
+ entries.forEach((e, i) => {
334
+ const address = live[i]!;
335
+ if (address === null || same(address, e.toUrl)) after.delete(e.to);
336
+ else after.set(e.to, address);
337
+ });
338
+ const drop = new Set([...before.keys()].filter((f) => !after.has(f)));
339
+ const set: Record<string, string> = {};
340
+ for (const [f, v] of after) if (before.get(f) !== v) set[f] = v;
341
+ const key = movesKey(this.prefix);
342
+ const field = (file: string) => `${ADDRESS_FIELD}${file}`;
343
+ if (drop.size) {
344
+ const fields = [...drop].map(field);
345
+ await landed(this.cache, key, () => this.redis.hdel(key, ...fields));
346
+ this.cache?.hash(key, {}, fields);
347
+ }
348
+ if (Object.keys(set).length) {
349
+ const fields = Object.fromEntries(Object.entries(set).map(([f, v]) => [field(f), v]));
350
+ await landed(this.cache, key, () => this.redis.hset(key, fields));
351
+ this.cache?.hash(key, fields);
352
+ }
353
+ }
354
+
274
355
  /** A deleted document's move, if it was moved, is no move any more: it's a deletion. */
275
356
  async forgetMoveTo(file: string): Promise<void> {
276
357
  const moves = await this.moves();
277
358
  const olds = [...moves].filter(([, t]) => t === file).map(([o]) => o);
278
359
  if (olds.length) await this.dropMoves(...olds);
360
+ if ((await this.addresses()).has(file)) {
361
+ const key = movesKey(this.prefix);
362
+ const field = `${ADDRESS_FIELD}${file}`;
363
+ await landed(this.cache, key, () => this.redis.hdel(key, field));
364
+ this.cache?.hash(key, {}, [field]);
365
+ }
279
366
  }
280
367
 
281
368
  /** The merge's ancestor for an autosave (Decision 6). See `ancestorFor`. */
@@ -0,0 +1,131 @@
1
+ /**
2
+ * CaraCMS Cloud as the site's GitHub credential (caracms-cloud M2, Decisions 11–14).
3
+ *
4
+ * - `CARACMS_CLOUD_KEY` (the site key, `cck_…`) is sent as `Authorization: Bearer` and
5
+ * nowhere else; `CARACMS_CLOUD_URL` overrides the production address below.
6
+ * - `cloudSettings()`: `GET /v1/settings` at start, for the site's repo (`github.repo`;
7
+ * null is `no_repo`, with the cloud's own sentence).
8
+ * - `cloudToken()`: `POST /v1/github/token`, a fresh installation token on the site's repo,
9
+ * about an hour long. `GitHubClient` calls it and refreshes five minutes early.
10
+ * - **A refusal** (any answer that isn't 2xx, carrying `{ error, message }`) throws a
11
+ * `CloudRefusal` with the cloud's `message` as it came, and `installUrl` when there is
12
+ * one. A cloud that can't be reached, or answers with something else, throws a
13
+ * `CloudRefusal` whose `error` is `unreachable`, with the package's own sentence.
14
+ *
15
+ * - `cloudUnavailable()`: the sign-in guard's 503 for a refusal at start (Decision 14),
16
+ * the sentence escaped and the install link a link.
17
+ *
18
+ * The key and the tokens are never logged and never put in an error.
19
+ */
20
+
21
+ /** The production API's address (caracms-cloud M0 → Contracts). The one place it's written. */
22
+ export const DEFAULT_CLOUD_URL = 'https://glad-bird-412.ap-southeast-2.convex.site';
23
+
24
+ /** A site key: `cck_` and 43 base64url characters (caracms-cloud M1). */
25
+ export const CLOUD_KEY_SHAPE = /^cck_[A-Za-z0-9_-]{43}$/;
26
+
27
+ export const CLOUD_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so the editor can't start. Try again in a minute.";
28
+ /** The cloud's own `no_repo` sentence (Contracts), for `github.repo: null` in the settings. */
29
+ export const CLOUD_NO_REPO = 'This site has no GitHub repo in CaraCMS Cloud yet. Ask CaraCMS to set one.';
30
+ /** Decision 13: a token answer named another repo than the one the server started with. */
31
+ export const CLOUD_REPO_CHANGED = (repo: string) => `This site's repo was changed in CaraCMS Cloud to ${repo}. Restart the server to use it.`;
32
+
33
+ /** How long a call to the cloud may take before it counts as unreachable. */
34
+ export const CLOUD_TIMEOUT_MS = 20_000;
35
+
36
+ export interface CloudConnection {
37
+ key: string;
38
+ url: string;
39
+ /** For tests. Default: global fetch. */
40
+ fetch?: typeof fetch;
41
+ timeoutMs?: number;
42
+ }
43
+
44
+ /** The cloud said no (or couldn't be reached). `message` is safe to show and to log. */
45
+ export class CloudRefusal extends Error {
46
+ /** The cloud's `error` (`no_repo`, `app_not_installed`, `unknown_key`…), or the package's `unreachable` / `repo_changed`. */
47
+ readonly error: string;
48
+ /** An `https://` link the message names (the App's install page), or null. */
49
+ readonly installUrl: string | null;
50
+ constructor(error: string, message: string, installUrl: string | null = null) {
51
+ super(message);
52
+ this.name = 'CloudRefusal';
53
+ this.error = error;
54
+ this.installUrl = installUrl;
55
+ }
56
+ }
57
+
58
+ const unreachable = () => new CloudRefusal('unreachable', CLOUD_UNREACHABLE);
59
+
60
+ async function call(connection: CloudConnection, method: 'GET' | 'POST', path: string): Promise<Record<string, unknown>> {
61
+ let res: Response;
62
+ let body: unknown;
63
+ try {
64
+ res = await (connection.fetch ?? fetch)(`${connection.url}${path}`, {
65
+ method,
66
+ headers: { authorization: `Bearer ${connection.key}`, accept: 'application/json', 'user-agent': 'caracms' },
67
+ signal: AbortSignal.timeout(connection.timeoutMs ?? CLOUD_TIMEOUT_MS),
68
+ });
69
+ body = await res.json();
70
+ } catch {
71
+ throw unreachable();
72
+ }
73
+ const answer = body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
74
+ if (!answer) throw unreachable();
75
+ if (!res.ok) {
76
+ const { error, message, installUrl } = answer;
77
+ if (typeof error !== 'string' || typeof message !== 'string' || !message.trim()) throw unreachable();
78
+ throw new CloudRefusal(error, message, typeof installUrl === 'string' && /^https:\/\//i.test(installUrl) ? installUrl : null);
79
+ }
80
+ return answer;
81
+ }
82
+
83
+ const REPO = /^([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/;
84
+
85
+ /** The site's repo, from `GET /v1/settings`. */
86
+ export async function cloudSettings(connection: CloudConnection): Promise<{ owner: string; repo: string }> {
87
+ const answer = await call(connection, 'GET', '/v1/settings');
88
+ const github = answer.github as { repo?: unknown } | undefined;
89
+ if (!github || typeof github !== 'object') throw unreachable();
90
+ if (github.repo === null) throw new CloudRefusal('no_repo', CLOUD_NO_REPO);
91
+ const m = typeof github.repo === 'string' ? REPO.exec(github.repo) : null;
92
+ if (!m) throw unreachable();
93
+ return { owner: m[1]!, repo: m[2]! };
94
+ }
95
+
96
+ /** A fresh installation token on the site's repo, from `POST /v1/github/token`. */
97
+ export async function cloudToken(connection: CloudConnection): Promise<{ token: string; expiresAt: number; repo: string }> {
98
+ const answer = await call(connection, 'POST', '/v1/github/token');
99
+ const { token, expiresAt, repo } = answer;
100
+ const at = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
101
+ if (typeof token !== 'string' || !token || Number.isNaN(at) || typeof repo !== 'string') throw unreachable();
102
+ return { token, expiresAt: at, repo };
103
+ }
104
+
105
+ const escapeHtml = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
106
+
107
+ /**
108
+ * The message as HTML, escaped, with `link` (when there is one) as a link: where the
109
+ * message names it, in place; otherwise after it. The message's words are never changed.
110
+ */
111
+ export function messageHtml(message: string, link: string | null): string {
112
+ if (!link) return escapeHtml(message);
113
+ const a = `<a href="${escapeHtml(link)}">${escapeHtml(link)}</a>`;
114
+ const parts = message.split(link);
115
+ return parts.length > 1 ? parts.map(escapeHtml).join(a) : `${escapeHtml(message)} ${a}`;
116
+ }
117
+
118
+ /** The cloud's refusal at start (Decision 14): its sentence, the install link as a link. */
119
+ export function cloudUnavailable(refusal: CloudRefusal, api: boolean): Response {
120
+ const headers = { 'Cache-Control': 'private, no-store' };
121
+ if (api) {
122
+ return new Response(JSON.stringify({ error: refusal.message, code: refusal.error, installUrl: refusal.installUrl }), {
123
+ status: 503,
124
+ headers: { ...headers, 'Content-Type': 'application/json; charset=utf-8' },
125
+ });
126
+ }
127
+ return new Response(
128
+ `<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>Editor unavailable</title><p>${messageHtml(refusal.message, refusal.installUrl)}</p>`,
129
+ { status: 503, headers: { ...headers, 'Content-Type': 'text/html; charset=utf-8' } },
130
+ );
131
+ }
@@ -34,8 +34,9 @@
34
34
  import { join, relative, sep } from 'node:path';
35
35
  import { SITE_SETTINGS_FILE, type ResolvedSite } from '../config.ts';
36
36
  import { filesSource, listDocuments, readDocument, resolveDocumentFile } from '../content.ts';
37
- import { planCreate, planDelete, refused } from '../editor/server/create.ts';
38
- import { moveChanges, moveResponse, planMove, settingsWithoutRedirectFrom } from '../editor/server/move.ts';
37
+ import { assertSettingsReadable, orderEdit, planArrange } from '../editor/server/arrange.ts';
38
+ import { planCreate, planDelete, refused, target } from '../editor/server/create.ts';
39
+ import { movesChanges, moveResponse, movesUnder, planMoves, settingsWithoutRedirectFrom, type ManyMoveRequest, type MovePlan } from '../editor/server/move.ts';
39
40
  import { replaceChanges, replaceResponse } from '../editor/server/replace.ts';
40
41
  import { parseAnyDocument } from '../document.ts';
41
42
  import type { DraftsStore } from '../editor/server/drafts-store.ts';
@@ -268,6 +269,127 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
268
269
  if (!(before && before.lock.tab === tab)) await locks.release(SITE_SETTINGS_FILE, tab, person);
269
270
  };
270
271
 
272
+ /**
273
+ * The many-move engine (collections under pages, Contracts; pre-M7 item 8's move, for any
274
+ * number of documents): the plan is checked as a whole against the listing with every
275
+ * autosave on top (move.ts → `planMoves`), then:
276
+ *
277
+ * - **Every file it writes is this tab's before anything is written** (`takeAll`): each
278
+ * moved document's old and new file, every linking file, Site Settings. Someone else's
279
+ * lock on any one refuses the whole move (nothing is changed), naming them and the
280
+ * document; the mover's own lock in another tab moves to this one. A held document whose
281
+ * file stays (its holder moved) is written, and locked, only when its own links change.
282
+ * - **It lands whole or leaves nothing behind.** Each autosave it stores is noted with the
283
+ * one it replaced; any failure puts them back newest first, compare-and-set against what
284
+ * the move wrote, and releases every lock it took. The new documents first, then the
285
+ * linking files, a file another move vacates written over (a swap), the old files gone,
286
+ * and Site Settings (the one autosave the sweep commits at once) last, so a sweep tick
287
+ * can't commit a redirect for a move that then fails.
288
+ * - **A document that was never live gets no redirect**: one made since the last Publish,
289
+ * or one already moved since then (its live address already redirects, and follows).
290
+ * But when a move vacates an address that is another document's live address (a chain
291
+ * before the Publish: A → B, then B's document to C, so B had none), it redirects to
292
+ * where that document is now, so the old public address still finds it.
293
+ * Each refiled document's move is recorded for Publish ("Moved from"), and every changed
294
+ * address's live one (autosaves.ts → `recordAddresses`), so a held document whose file
295
+ * stays is named too.
296
+ */
297
+ const moveMany = (request: ManyMoveRequest, context?: WriteContext): Promise<MoveResponse> =>
298
+ guard(async () => {
299
+ const { person, tab } = who(context);
300
+ const documents = await listed();
301
+ const plans = planMoves(site, request.moves, documents);
302
+ const source = overlay((await autosaves.all()).map((r) => r.value));
303
+ const addresses = await autosaves.addresses();
304
+ const onMain = (file: string) => git.changeOf(file, '') === 'changed';
305
+ const wasLive = (p: MovePlan) => onMain(p.from.file) && !addresses.has(p.from.file);
306
+ // A live address a chain left without a redirect gets one once a move vacates it (move.ts → `movesChanges`).
307
+ const liveAddressOf = request.redirect === false ? undefined : (file: string) => addresses.get(file) ?? null;
308
+ const changes = await movesChanges(site, plans, documents, (file) => source.readText(join(site.root, file)), (p) => request.redirect !== false && wasLive(p), request.editSettings, liveAddressOf);
309
+ const refiled = plans.filter((p) => p.refiled);
310
+ const vacated = new Set(refiled.map((p) => p.from.file));
311
+ const targets = new Set(refiled.map((p) => p.to.file));
312
+ const files = [...new Set([...refiled.flatMap((p) => [p.from.file, p.to.file]), ...changes.relinked.map((r) => r.file), ...(changes.settings ? [SITE_SETTINGS_FILE] : [])])];
313
+ const root = plans[0]!;
314
+ const movingWith = new Map(plans.filter((p) => !p.refiled).map((p) => [p.from.file, p]));
315
+
316
+ const took: string[] = [];
317
+ const written: Written[] = [];
318
+ try {
319
+ await takeAll(files, tab, person, took, (file, who, mine) => {
320
+ const elsewhere = mine ? ' in another tab' : '';
321
+ const until = `so the address can't change until ${mine ? 'that tab is closed' : "they're done"}.`;
322
+ const from = refiled.find((p) => p.from.file === file);
323
+ if (from) return `${who} editing ${from.title}${elsewhere}, so ${from === root ? 'its' : `${root.title}'s`} address can't change yet.`;
324
+ const to = refiled.find((p) => p.to.file === file);
325
+ if (to) return `${who} working on ${to.to.url} right now, so ${to.title} can't move there yet.`;
326
+ if (file === SITE_SETTINGS_FILE) return `${who} editing Site Settings${elsewhere}, which holds the redirects, ${until}`;
327
+ const name = changes.relinked.find((r) => r.file === file)?.title ?? file;
328
+ return movingWith.has(file) ? `${who} editing ${name}${elsewhere}, which moves with ${root.title}, ${until}` : `${who} editing ${name}${elsewhere}, which links to ${plans.length === 1 ? root.from.url : 'an address that changes'}, ${until}`;
329
+ });
330
+
331
+ const t = now();
332
+ const at = new Date(t).toISOString();
333
+ // The new documents first, as a create makes one: an autosave with no base in drafts.
334
+ // Made there since the listing was read: it exists. A pending deletion there is replaced.
335
+ for (const { plan, text } of changes.moved) {
336
+ if (vacated.has(plan.to.file)) continue;
337
+ for (let attempt = 1; ; attempt++) {
338
+ if (attempt > 6) throw new Error('caracms: the autosave kept changing under the move.');
339
+ const existing = await autosaves.get(plan.to.file);
340
+ const over = git.read(plan.to.file);
341
+ if ((existing && !existing.value.gone) || (!existing && over !== null)) throw refused(409, 'address-taken', `There's already a page at ${plan.to.url}.`);
342
+ const next: Autosave = { ...saved(plan.to.file, text, plan.to.collection, person, tab, t)(null, null), base: text, created: { over } };
343
+ if (await autosaves.putIf(existing, next)) {
344
+ written.push({ prior: existing, wrote: { raw: JSON.stringify(next), value: next } });
345
+ break;
346
+ }
347
+ }
348
+ }
349
+ for (const r of changes.relinked) written.push(await stash(r.file, tab, saved(r.file, r.text, r.collection, person, tab, t)));
350
+ // A file another move in the plan vacates (a swap) is written over, as an edit.
351
+ for (const { plan, text } of changes.moved) if (vacated.has(plan.to.file)) written.push(await stash(plan.to.file, tab, saved(plan.to.file, text, plan.to.collection, person, tab, t)));
352
+ // Then the old ones go, as a delete removes one.
353
+ for (const plan of refiled) {
354
+ if (targets.has(plan.from.file)) continue;
355
+ const oldTitle = titleOf(plan.from.file, git.read(plan.from.file) ?? (await source.readText(join(site.root, plan.from.file))));
356
+ written.push(
357
+ await stash(plan.from.file, tab, (prior, current) => ({
358
+ file: plan.from.file,
359
+ base: prior ? prior.base : (current ?? ''),
360
+ ...carried(prior),
361
+ document: null,
362
+ text: null,
363
+ editor: person,
364
+ tab,
365
+ firstAt: prior?.firstAt ?? t,
366
+ updatedAt: t,
367
+ seq: null,
368
+ ...(prior?.created ? { created: prior.created } : {}),
369
+ gone: { title: oldTitle },
370
+ })),
371
+ );
372
+ }
373
+ // The redirects last (see above).
374
+ if (changes.settings) written.push(await stash(SITE_SETTINGS_FILE, tab, saved(SITE_SETTINGS_FILE, changes.settings.after, 'settings', person, tab, t)));
375
+
376
+ const texts: [string, string][] = [...changes.moved.map((m): [string, string] => [m.plan.to.file, m.text]), ...changes.relinked.map((r): [string, string] => [r.file, r.text]), ...(changes.settings ? [[SITE_SETTINGS_FILE, changes.settings.after] as [string, string]] : [])];
377
+ for (const [file, text] of texts) await autosaves.setChanged(file, { by: person, version: fileVersion(text), at });
378
+ for (const plan of refiled) if (!targets.has(plan.from.file)) await autosaves.setChanged(plan.from.file, { by: person, version: '', at, gone: true });
379
+ // The moves themselves, kept until they're published: the waiting list and Publish read them, not the redirects.
380
+ await autosaves.recordMoves(refiled.map((plan) => ({ from: plan.from.file, to: plan.to.file, wasLive: onMain(plan.from.file) })));
381
+ await autosaves.recordAddresses(plans.map((p) => ({ from: p.from.file, to: p.to.file, fromUrl: p.from.url, toUrl: p.to.url, wasLive: onMain(p.from.file) })));
382
+ } catch (error) {
383
+ await undo(written, took, tab, person);
384
+ throw error;
385
+ }
386
+ // The tab moves on to the asked-for document's new address, keeping its lock there; the others go once committed.
387
+ for (const plan of refiled) if (!targets.has(plan.from.file)) await locks.release(plan.from.file, tab, person, { deleted: true });
388
+ for (const f of took) if (f !== root.to.file && !(vacated.has(f) && !targets.has(f))) await locks.release(f, tab, person);
389
+ sweep.poke();
390
+ return moveResponse(plans, changes);
391
+ });
392
+
271
393
  return {
272
394
  source: () =>
273
395
  guard(async () => overlay((await autosaves.all()).map((r) => r.value))),
@@ -414,92 +536,63 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
414
536
  return { ok: true, collection: plan.collection, path: plan.path, file: plan.file, url: plan.url, version };
415
537
  }),
416
538
 
417
- move: (request, context): Promise<MoveResponse> =>
539
+ move: async (request, context): Promise<MoveResponse> => {
540
+ const documents = await listed();
541
+ const moves = movesUnder(site, request, documents);
542
+ // Arrange pages (Decision 6): a saved order follows the move, in the same Site Settings write.
543
+ return moveMany({ moves, ...(request.redirect === false ? { redirect: false } : {}), editSettings: orderEdit(site, moves, documents) }, context);
544
+ },
545
+
546
+ moveMany: (request, context) => moveMany(request, context),
547
+
548
+ /**
549
+ * Arrange pages (Contracts): the plan against the listing with every autosave on top,
550
+ * then the moves through `moveMany` (every lock up front, someone else's on any file
551
+ * refuses it all, a rollback on any failure) with the arranged order riding its Site
552
+ * Settings write. The engine keeps the first document's lock, as Change address…'s tab
553
+ * moves on to it; the arrange screen has no document open, so it's let go here. With no
554
+ * moves (Decision 21), Site Settings alone, under its lock, as one autosave.
555
+ */
556
+ arrange: (request, context) =>
418
557
  guard(async () => {
419
558
  const { person, tab } = who(context);
420
- const documents = await listed();
421
- const plan = planMove(site, request, documents);
422
- const source = overlay((await autosaves.all()).map((r) => r.value));
423
- // A document made since the last Publish was never live, so nobody has its old address: no redirect.
424
- const wasLive = git.changeOf(plan.from.file, '') === 'changed';
425
- const changes = await moveChanges(site, plan, documents, (file) => source.readText(join(site.root, file)), wasLive);
426
- const relinked = changes.relinked.map((r) => r.file);
427
- const files = [plan.from.file, plan.to.file, ...relinked, ...(changes.settings ? [SITE_SETTINGS_FILE] : [])];
428
-
429
- // Every file the move writes is this tab's before anything is written. Someone else's
430
- // lock on one refuses the whole move (nothing is changed), naming them and the document;
431
- // the mover's own lock in another tab moves to this one (that tab goes read-only, as after Edit here).
432
- //
433
- // **A move lands whole or leaves nothing behind.** Each autosave it stores is noted with the
434
- // one it replaced; any failure after the first (Redis gone, an autosave that kept changing)
435
- // puts them back newest first, compare-and-set against what the move wrote, and releases
436
- // every lock the move took, so a retry finds the document where it was. Site Settings (the
437
- // one autosave the sweep commits at once) is stored last, so a sweep tick can't commit a
438
- // redirect for a move that then fails.
559
+ const plan = planArrange(site, request, await listed());
560
+ // An unreadable Site Settings would drop the order and the redirects without a word: refuse every Apply.
561
+ if (plan.moves.length) assertSettingsReadable(site, (await autosaves.get(SITE_SETTINGS_FILE))?.value.text ?? git.read(SITE_SETTINGS_FILE));
562
+ if (plan.moves.length) {
563
+ const first = plan.moves[0]!;
564
+ const kept = target(site, first.collection, first.to).file;
565
+ const before = await locks.read(kept);
566
+ await moveMany({ moves: plan.moves, ...(request.redirect === false ? { redirect: false } : {}), editSettings: (text) => plan.edit(text) ?? text }, context);
567
+ if (!(before && before.lock.tab === tab)) await locks.release(kept, tab, person);
568
+ return { ok: true as const };
569
+ }
439
570
  const took: string[] = [];
440
571
  const written: Written[] = [];
441
572
  try {
442
- await takeAll(files, tab, person, took, (file, who, mine) => {
443
- const name = file === plan.from.file ? plan.title : file === SITE_SETTINGS_FILE ? 'Site Settings' : (changes.relinked.find((r) => r.file === file)?.title ?? plan.to.url);
444
- return file === plan.from.file
445
- ? `${who} editing ${name}${mine ? ' in another tab' : ''}, so its address can't change yet.`
446
- : file === plan.to.file
447
- ? `${who} working on ${plan.to.url} right now, so ${plan.title} can't move there yet.`
448
- : `${who} editing ${name}${mine ? ' in another tab' : ''}, which ${file === SITE_SETTINGS_FILE ? 'holds the redirects' : `links to ${plan.from.url}`}, so the address can't change until ${mine ? 'that tab is closed' : 'they\'re done'}.`;
449
- });
450
-
451
- const t = now();
452
- const at = new Date(t).toISOString();
453
- // The new document first, as a create makes one: its autosave, with no base in drafts.
454
- // Made there since the listing was read: it exists. A pending deletion there is replaced.
455
- for (let attempt = 1; ; attempt++) {
456
- if (attempt > 6) throw new Error('caracms: the autosave kept changing under the move.');
457
- const existing = await autosaves.get(plan.to.file);
458
- const over = git.read(plan.to.file);
459
- if ((existing && !existing.value.gone) || (!existing && over !== null)) throw refused(409, 'address-taken', `There's already a page at ${plan.to.url}.`);
460
- const next: Autosave = { ...saved(plan.to.file, changes.moved, plan.to.collection, person, tab, t)(null, null), base: changes.moved, created: { over } };
461
- if (await autosaves.putIf(existing, next)) {
462
- written.push({ prior: existing, wrote: { raw: JSON.stringify(next), value: next } });
463
- break;
464
- }
465
- }
466
- for (const r of changes.relinked) written.push(await stash(r.file, tab, saved(r.file, r.text, r.collection, person, tab, t)));
467
- // Then the old one goes, as a delete removes one.
468
- const oldTitle = titleOf(plan.from.file, git.read(plan.from.file) ?? (await source.readText(join(site.root, plan.from.file))));
469
- written.push(
470
- await stash(plan.from.file, tab, (prior, current) => ({
471
- file: plan.from.file,
472
- base: prior ? prior.base : (current ?? ''),
473
- ...carried(prior),
474
- document: null,
475
- text: null,
476
- editor: person,
477
- tab,
478
- firstAt: prior?.firstAt ?? t,
479
- updatedAt: t,
480
- seq: null,
481
- ...(prior?.created ? { created: prior.created } : {}),
482
- gone: { title: oldTitle },
483
- })),
484
- );
485
- // The redirect last (see above).
486
- if (changes.settings) written.push(await stash(SITE_SETTINGS_FILE, tab, saved(SITE_SETTINGS_FILE, changes.settings.after, 'settings', person, tab, t)));
487
-
488
- for (const [file, text] of [[plan.to.file, changes.moved], ...changes.relinked.map((r) => [r.file, r.text]), ...(changes.settings ? [[SITE_SETTINGS_FILE, changes.settings.after]] : [])] as [string, string][]) {
489
- await autosaves.setChanged(file, { by: person, version: fileVersion(text), at });
573
+ await takeAll([SITE_SETTINGS_FILE], tab, person, took, (_file, who, mine) => `${who} editing Site Settings${mine ? ' in another tab' : ''}, which holds the order of the Pages tree, so the order can't change until ${mine ? 'that tab is closed' : "they're done"}.`);
574
+ const current = (await autosaves.get(SITE_SETTINGS_FILE))?.value.text ?? git.read(SITE_SETTINGS_FILE);
575
+ assertSettingsReadable(site, current);
576
+ const next = plan.edit(current);
577
+ if (next !== null && next !== current) {
578
+ const t = now();
579
+ let after = next;
580
+ written.push(
581
+ await stash(SITE_SETTINGS_FILE, tab, (prior, drafts) => {
582
+ const base = prior ? prior.text : drafts;
583
+ after = plan.edit(base) ?? base ?? next;
584
+ return saved(SITE_SETTINGS_FILE, after, 'settings', person, tab, t)(prior, drafts);
585
+ }),
586
+ );
587
+ await autosaves.setChanged(SITE_SETTINGS_FILE, { by: person, version: fileVersion(after), at: new Date(t).toISOString() });
490
588
  }
491
- await autosaves.setChanged(plan.from.file, { by: person, version: '', at, gone: true });
492
- // The move itself, kept until it's published: the waiting list and Publish read it, not the redirect.
493
- await autosaves.recordMove(plan.from.file, plan.to.file, wasLive);
494
589
  } catch (error) {
495
590
  await undo(written, took, tab, person);
496
591
  throw error;
497
592
  }
498
- // The tab moves on to the new address, keeping its lock there; the others go once committed.
499
- await locks.release(plan.from.file, tab, person, { deleted: true });
500
- for (const f of took) if (f !== plan.to.file && f !== plan.from.file) await locks.release(f, tab, person);
593
+ for (const f of took) await locks.release(f, tab, person);
501
594
  sweep.poke();
502
- return moveResponse(site, plan, changes);
595
+ return { ok: true as const };
503
596
  }),
504
597
 
505
598
  /**
@@ -23,7 +23,10 @@
23
23
  * State lives in Redis and git (two servers run during a deploy); what's held here
24
24
  * is the sweep's "am I ticking" and git.ts's caches.
25
25
  */
26
+ import { orderIn } from '../editor/server/arrange.ts';
27
+ import { pageOrderStamp } from '../page-order.ts';
26
28
  import { SITE_SETTINGS_FILE, type ResolvedSite } from '../config.ts';
29
+ import { filesSource, heldBasesOf } from '../content.ts';
27
30
  import { brandFrom, editorBrand } from '../editor/brand.ts';
28
31
  import type { DraftsStore } from '../editor/server/drafts-store.ts';
29
32
  import { handleCreate, handleDelete } from '../editor/server/create.ts';
@@ -42,7 +45,7 @@ import type {
42
45
  TryAgainResponse,
43
46
  WaitingView,
44
47
  } from '../editor/types.ts';
45
- import { Autosaves } from './autosaves.ts';
48
+ import { Autosaves, splitMoves } from './autosaves.ts';
46
49
  import { lastChangeOf, liveDraftsStore, unavailable } from './drafts-store.ts';
47
50
  import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf } from './git.ts';
48
51
  import { accessProblemMessage, type GitHubAccessProblem } from './github.ts';
@@ -63,6 +66,7 @@ const ACCESS_TITLE: Record<GitHubAccessProblem['kind'], string> = {
63
66
  refused: 'GitHub refused this site',
64
67
  permission: 'GitHub refused a change',
65
68
  expiring: "This site's GitHub token expires soon",
69
+ cloud: 'No GitHub access through CaraCMS Cloud',
66
70
  };
67
71
 
68
72
  export interface LiveEditor {
@@ -321,10 +325,24 @@ export async function createLiveEditor(
321
325
  // Pre-M7 item 8: a moved document is one entry, on its new file, with its old address.
322
326
  // Read from the moves as recorded, not Site Settings' redirects: removing the redirect leaves it a move.
323
327
  const readNow = (f: string) => (textNow.has(f) ? (textNow.get(f) ?? null) : git.read(f));
324
- const moves = movesIn(site, [...waiting.keys()], readNow, new Map(snap.hash(autosaves.movesKey())));
328
+ // The copy with every autosave on top: where a holder is now, moved or not committed yet.
329
+ const nowSource = () => {
330
+ const files = new Map(git.files());
331
+ for (const a of saves) {
332
+ if (a.text === null) files.delete(a.file);
333
+ else files.set(a.file, a.text);
334
+ }
335
+ return filesSource(site.root, () => files);
336
+ };
337
+ const { records, addresses } = splitMoves(snap.hash(autosaves.movesKey()));
338
+ // Collections under pages: a held document's addresses come from its holder (read only when something moved).
339
+ const moved = records.size || addresses.size;
340
+ const moves = movesIn(site, [...waiting.keys()], readNow, records, moved ? await heldBasesOf(site, nowSource()) : {}, addresses);
325
341
  const redirects = moves.from.size ? redirectsIn(readNow(SITE_SETTINGS_FILE)) : [];
326
342
  for (const [file, from] of moves.from) {
327
- const w = waiting.get(file)!;
343
+ // A held document whose holder moved keeps its file, unchanged: it waits as a move all the same.
344
+ const w = waiting.get(file) ?? { file, change: 'changed' as const, ...docOf(site, file), title: titleOf(file, readNow(file)), editors: [], changedAt: null, keptDraft: false, uncommitted: false, editingNow: editingNow(file), missingAlt: [] };
345
+ waiting.set(file, w);
328
346
  w.movedFrom = from;
329
347
  w.movedRedirect = redirects.some((r) => r.from === from);
330
348
  }
@@ -357,9 +375,13 @@ export async function createLiveEditor(
357
375
  notices: Notices.forPerson(person, git.keptDraftNotices(), titleFor, snap.set(noticeStore.dismissedKey), snap.hash(noticeStore.noticesKey).values()),
358
376
  publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: LiveGit.publishRecordFrom(snap.string(git.publishRecordKey)) }),
359
377
  // Runs-anywhere Decision 5: while it holds, not dismissable.
360
- githubAccess: access ? { kind: access.kind, title: ACCESS_TITLE[access.kind], message: accessProblemMessage(access) } : null,
378
+ githubAccess: access
379
+ ? { kind: access.kind, title: ACCESS_TITLE[access.kind], message: accessProblemMessage(access), link: access.kind === 'cloud' ? access.installUrl : null }
380
+ : null,
361
381
  // M5a Decision 10: from the committed copy, never an autosave.
362
382
  brand: editorBrand(brandFrom(git.read(SITE_SETTINGS_FILE), site)),
383
+ // Arrange pages (Decision 18): from the autosaves already in the snapshot, else the copy; no Redis command of its own.
384
+ pageOrderStamp: pageOrderStamp(orderIn(readNow(SITE_SETTINGS_FILE))),
363
385
  };
364
386
  return json(200, body);
365
387
  });