caracms 0.4.0 → 0.5.1

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.
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Arrange pages (Contracts → Apply request): the arrange screen's Apply, both modes.
3
+ *
4
+ * **A security boundary, as move is** (move.ts): the paths come from the browser, so the
5
+ * route is the same POST-JSON-same-origin handler, and every move goes through the
6
+ * many-move engine (`moveMany`: create.ts → `target`, the store's own checks and locks).
7
+ *
8
+ * - `planArrange` expands each root with Change address…'s expansion (move.ts →
9
+ * `movesUnder`): everything under it, except what's under another root, which wins for its
10
+ * own subtree. It checks every `order` key and slug against the Pages collection as it
11
+ * will be once the moves land, and builds the engine's Site Settings edit: the saved order
12
+ * re-keyed after the moves, the request's lists replacing those keys (page-order.ts →
13
+ * `arrangedOrder`), so the order and the redirects land, or roll back, in one write.
14
+ * - With no moves (an order-only Apply, Decision 21), the store writes Site Settings alone,
15
+ * taking its lock in live mode (`DraftsStore.arrange`).
16
+ * - Change address… keeps the order current the same way (`orderEdit`, Decision 6).
17
+ */
18
+ import type { ResolvedCollection, ResolvedSite } from '../../config.ts';
19
+ import type { DocumentEntry } from '../../content.ts';
20
+ import { parseAnyDocument, type SettingsDocument } from '../../document.ts';
21
+ import type { Person } from '../../live/messages.ts';
22
+ import { arrangedOrder, isOrderedCollection, listingStamp, orderAfterMoves, parsePageOrder, type PageOrder, type PathChange } from '../../page-order.ts';
23
+ import { applyKeyedEdits } from '../../serialize.ts';
24
+ import type { ArrangeRequest, ArrangeResponse } from '../types.ts';
25
+ import { refused } from './create.ts';
26
+ import { movesUnder, type PathMove } from './move.ts';
27
+ import { checkOrigin, seqOf, tabOf, WriteRefused, type WriteContext } from './write.ts';
28
+
29
+ type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
30
+
31
+ /** An Apply's body can carry a whole tree's order and moves. */
32
+ export const MAX_ARRANGE_BODY_BYTES = 256 * 1024;
33
+
34
+ const ARRANGE_SHAPE = '{ moves: [{ collection, fromPath, toPath }…], order: { "<parent path>": ["slug"…] }, redirect?, expect? }';
35
+
36
+ /** Decision 10's sentence when the listing isn't the one the review was planned on. */
37
+ export const CHANGED_MESSAGE = 'The pages changed since you opened the review.';
38
+
39
+ /** Validate an Apply body's shape. Throws WriteRefused 400. */
40
+ export function parseArrangeBody(body: unknown): ArrangeRequest {
41
+ const bad = (why: string) => refused(400, 'bad-request', `Malformed body: ${why}. Send ${ARRANGE_SHAPE}.`);
42
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw bad('it must be a JSON object');
43
+ const b = body as Record<string, unknown>;
44
+ const extra = Object.keys(b).filter((k) => !['moves', 'order', 'redirect', 'expect'].includes(k));
45
+ if (extra.length) throw bad(`unexpected ${extra.map((k) => JSON.stringify(k.slice(0, 60))).join(', ')}`);
46
+ if (!Array.isArray(b.moves)) throw bad('"moves" must be a list');
47
+ const moves = b.moves.map((m, i) => {
48
+ if (!m || typeof m !== 'object' || Array.isArray(m)) throw bad(`moves[${i}] must be { collection, fromPath, toPath }`);
49
+ const r = m as Record<string, unknown>;
50
+ if (Object.keys(r).some((k) => !['collection', 'fromPath', 'toPath'].includes(k))) throw bad(`moves[${i}] must be { collection, fromPath, toPath }`);
51
+ if (typeof r.collection !== 'string' || r.collection === '') throw bad(`moves[${i}].collection must be a collection's name`);
52
+ if (typeof r.fromPath !== 'string' || r.fromPath === '') throw bad(`moves[${i}].fromPath must be the page's path`);
53
+ if (typeof r.toPath !== 'string') throw bad(`moves[${i}].toPath must be the page's new path`);
54
+ if (r.fromPath.length > 512 || r.toPath.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
55
+ return { collection: r.collection, fromPath: r.fromPath, toPath: r.toPath };
56
+ });
57
+ if (!b.order || typeof b.order !== 'object' || Array.isArray(b.order)) throw bad('"order" must be an object of lists');
58
+ const order: PageOrder = {};
59
+ for (const [key, list] of Object.entries(b.order as Record<string, unknown>)) {
60
+ if (!Array.isArray(list) || !list.every((s) => typeof s === 'string' && s !== '')) throw bad(`order[${JSON.stringify(key.slice(0, 60))}] must be a list of slugs`);
61
+ if (key.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
62
+ order[key] = [...new Set(list as string[])];
63
+ }
64
+ if (b.redirect !== undefined && typeof b.redirect !== 'boolean') throw bad('"redirect" must be true or false');
65
+ let expect: Record<string, string> | undefined;
66
+ if (b.expect !== undefined) {
67
+ if (!b.expect || typeof b.expect !== 'object' || Array.isArray(b.expect) || !Object.values(b.expect).every((v) => typeof v === 'string')) throw bad('"expect" must be { "<collection>": "<stamp>" }');
68
+ expect = { ...(b.expect as Record<string, string>) };
69
+ }
70
+ return { moves, order, ...(b.redirect === false ? { redirect: false } : {}), ...(expect ? { expect } : {}) };
71
+ }
72
+
73
+ const orderedCollection = (site: Pick<Site, 'collections'>): ResolvedCollection | undefined => Object.values(site.collections).find((c) => isOrderedCollection(c));
74
+ const settingsCollection = (site: Pick<Site, 'collections'>): ResolvedCollection | undefined => Object.values(site.collections).find((c) => c.kind === 'settings');
75
+
76
+ /** Site Settings' `pageOrder` in its text (`{}`: none, or the text isn't JSON). */
77
+ export function orderIn(text: string | null): PageOrder {
78
+ if (text === null) return {};
79
+ try {
80
+ const raw = JSON.parse(text) as unknown;
81
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? parsePageOrder((raw as Record<string, unknown>).pageOrder) : {};
82
+ } catch {
83
+ return {};
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Site Settings' text with `pageOrder` set to `order` (taken out when it's empty), every
89
+ * other key as it was; `text` null: a new file. Unchanged text when the order is the same,
90
+ * or when the text isn't JSON (the editor warns about that file).
91
+ */
92
+ export function settingsWithOrder(site: Pick<Site, 'collections' | 'blocks'>, text: string | null, order: PageOrder): string | null {
93
+ const collection = settingsCollection(site);
94
+ if (!collection) return text;
95
+ let doc: SettingsDocument | null = null;
96
+ if (text !== null) {
97
+ try {
98
+ doc = parseAnyDocument(JSON.parse(text), site, collection) as SettingsDocument;
99
+ } catch {
100
+ return text;
101
+ }
102
+ }
103
+ if (doc === null && Object.keys(order).length === 0) return text;
104
+ return applyKeyedEdits(text, { title: doc?.title ?? 'Site Settings', data: { ...(doc?.data ?? {}), pageOrder: order } }, collection);
105
+ }
106
+
107
+ /** The sentence an order-only Apply is refused with when Site Settings, which holds the order, can't be read. */
108
+ export const UNREADABLE_SETTINGS_MESSAGE = "Site Settings isn't valid JSON, so the new order can't be saved there. Nothing was changed: fix Site Settings, then apply again.";
109
+
110
+ /**
111
+ * An order-only Apply (Decision 21) writes nothing but Site Settings, so one that can't be read
112
+ * is refused (422 invalid-json) rather than answered `{ ok: true }` with the order dropped.
113
+ * `text` null (no file yet) is fine: the order makes a new one.
114
+ */
115
+ export function assertSettingsReadable(site: Pick<Site, 'collections' | 'blocks'>, text: string | null): void {
116
+ const collection = settingsCollection(site);
117
+ if (!collection || text === null) return;
118
+ try {
119
+ parseAnyDocument(JSON.parse(text), site, collection);
120
+ } catch {
121
+ throw refused(422, 'invalid-json', UNREADABLE_SETTINGS_MESSAGE);
122
+ }
123
+ }
124
+
125
+ /** The ordered collection's path changes in a list of moves. */
126
+ function pathChanges(site: Pick<Site, 'collections'>, moves: readonly PathMove[]): PathChange[] {
127
+ const pages = orderedCollection(site);
128
+ return pages ? moves.filter((m) => m.collection === pages.name).map((m) => ({ from: m.path, to: m.to })) : [];
129
+ }
130
+ const pathsOf = (site: Pick<Site, 'collections'>, documents: readonly DocumentEntry[]) => {
131
+ const pages = orderedCollection(site);
132
+ return pages ? documents.filter((d) => d.collection === pages.name).map((d) => d.path) : [];
133
+ };
134
+
135
+ /** Change address… (Decision 6): the engine's Site Settings edit that keeps a saved order current after these moves. */
136
+ export function orderEdit(site: Site, moves: readonly PathMove[], documents: readonly DocumentEntry[]): (text: string) => string {
137
+ const changes = pathChanges(site, moves);
138
+ const before = pathsOf(site, documents);
139
+ return (text) => {
140
+ const saved = orderIn(text);
141
+ if (changes.length === 0 || Object.keys(saved).length === 0) return text;
142
+ return settingsWithOrder(site, text, orderAfterMoves(saved, changes, before)) ?? text;
143
+ };
144
+ }
145
+
146
+ export interface ArrangePlan {
147
+ /** Every document whose path changes, roots first (each root, then what it carries); empty for an order-only Apply. */
148
+ moves: PathMove[];
149
+ /** Site Settings' text → its text with the arranged order (the engine's `editSettings`, or the order-only write). */
150
+ edit: (text: string | null) => string | null;
151
+ }
152
+
153
+ /**
154
+ * The Apply's plan against the listing as it is (`documents`): the roots expanded, the
155
+ * order checked against the Pages collection as it will be. Throws WriteRefused: the
156
+ * engine's own refusals for a root (move.ts → `movesUnder`), `not-found` (404) for an
157
+ * order entry that names nothing once the moves land.
158
+ */
159
+ export function planArrange(site: Site, request: ArrangeRequest, documents: readonly DocumentEntry[]): ArrangePlan {
160
+ // Decision 10: the review's listing, collection by collection, before anything is planned.
161
+ for (const [collection, stamp] of Object.entries(request.expect ?? {})) {
162
+ const paths = documents.filter((d) => d.collection === collection).map((d) => d.path);
163
+ if (listingStamp(paths) !== stamp) throw refused(409, 'changed', CHANGED_MESSAGE);
164
+ }
165
+ const key = (m: { collection: string; path: string }) => `${m.collection}\0${m.path}`;
166
+ // Shallowest first, so an inner root wins for its own subtree.
167
+ const roots = [...request.moves].sort((a, b) => a.fromPath.split('/').length - b.fromPath.split('/').length);
168
+ const seen = new Set<string>();
169
+ for (const r of roots) {
170
+ const k = key({ collection: r.collection, path: r.fromPath });
171
+ if (seen.has(k)) throw refused(400, 'bad-request', `${r.fromPath} is in the plan twice.`);
172
+ seen.add(k);
173
+ }
174
+ const planned = new Map<string, PathMove>();
175
+ for (const r of roots) {
176
+ for (const m of movesUnder(site, { collection: r.collection, path: r.fromPath, to: r.toPath }, documents)) planned.set(key(m), m);
177
+ }
178
+ // Roots in the order asked, each followed by what it carries (what the innermost root above it carries).
179
+ const ownerOf = (m: PathMove) =>
180
+ request.moves.filter((o) => o.collection === m.collection && (m.path === o.fromPath || m.path.startsWith(`${o.fromPath}/`))).sort((a, b) => b.fromPath.length - a.fromPath.length)[0]!;
181
+ const ordered: PathMove[] = [];
182
+ for (const r of request.moves) {
183
+ ordered.push(planned.get(key({ collection: r.collection, path: r.fromPath }))!);
184
+ for (const m of planned.values()) if (m.path !== r.fromPath && ownerOf(m) === r) ordered.push(m);
185
+ }
186
+
187
+ // The Pages collection once the moves land: every `order` key and slug names something there.
188
+ const changes = pathChanges(site, ordered);
189
+ const before = pathsOf(site, documents);
190
+ const moving = new Map(changes.map((c) => [c.from, c.to]));
191
+ const after = before.map((p) => moving.get(p) ?? p);
192
+ const known = new Set<string>(['']);
193
+ for (const p of after) {
194
+ const parts = p.split('/');
195
+ for (let i = 1; i <= parts.length; i++) known.add(parts.slice(0, i).join('/'));
196
+ }
197
+ for (const [parent, slugs] of Object.entries(request.order)) {
198
+ if (!known.has(parent)) throw refused(404, 'not-found', `There's no page or group at /${parent} any more.`);
199
+ for (const slug of slugs) {
200
+ const path = parent === '' ? slug : `${parent}/${slug}`;
201
+ if (!known.has(path)) throw refused(404, 'not-found', `There's no page or group at /${path} any more.`);
202
+ }
203
+ }
204
+ const edit = (text: string | null) => settingsWithOrder(site, text, arrangedOrder(orderIn(text), request.order, changes, before));
205
+ return { moves: ordered, edit };
206
+ }
207
+
208
+ // ── The route ──────────────────────────────────────────────────────────────
209
+
210
+ const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
211
+ new Response(JSON.stringify(body), {
212
+ status,
213
+ headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
214
+ });
215
+
216
+ /** What the route needs of the drafts store. */
217
+ export interface Arranges {
218
+ arrange(request: ArrangeRequest, context?: WriteContext): Promise<ArrangeResponse>;
219
+ }
220
+
221
+ /** POST /_caracms/arrange: a Request in, a Response out, through the mode's store (move.ts's handling, the same checks). */
222
+ export async function handleArrange(request: Request, store: Arranges, who: { person?: Person | null } = {}): Promise<Response> {
223
+ try {
224
+ if (request.method !== 'POST') throw new WriteRefused(405, 'The arrange route only takes POST.');
225
+ checkOrigin(request);
226
+ const type = request.headers.get('content-type') ?? '';
227
+ if (!/^application\/json\s*(;|$)/i.test(type)) throw new WriteRefused(415, 'Send JSON (Content-Type: application/json).');
228
+ const length = Number(request.headers.get('content-length') ?? 0);
229
+ if (length > MAX_ARRANGE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
230
+ const raw = await request.text();
231
+ if (Buffer.byteLength(raw) > MAX_ARRANGE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
232
+ let body: unknown;
233
+ try {
234
+ body = JSON.parse(raw);
235
+ } catch {
236
+ throw refused(400, 'bad-request', 'Malformed body: it isn\'t valid JSON.');
237
+ }
238
+ const context: WriteContext = { person: who.person ?? null, tab: tabOf(request), release: false, seq: seqOf(request) };
239
+ return json(200, await store.arrange(parseArrangeBody(body), context));
240
+ } catch (error) {
241
+ if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body }, error.status === 405 ? { Allow: 'POST' } : {});
242
+ console.error('[caracms] arrange failed:', error);
243
+ return json(500, { error: `That didn't work (${error instanceof Error ? error.message : String(error)}).` });
244
+ }
245
+ }
@@ -6,12 +6,13 @@
6
6
  *
7
7
  * Everything reads at request time (never imported, Decision 5), and keeps no state.
8
8
  */
9
- import { stat } from 'node:fs/promises';
9
+ import { stat, writeFile } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { SITE_SETTINGS_FILE, type ResolvedSite } from '../../config.ts';
12
12
  import { diskSource, listDocuments, readDocument, type ContentSource } from '../../content.ts';
13
- import type { AnyOpenedDocument, CreateRequest, CreateResponse, DeleteRequest, DeleteResponse, ListedDocument, MoveRequest, MoveResponse, OpenedDocument, ReplaceFileRequest, ReplaceFileResponse } from '../types.ts';
13
+ import type { AnyOpenedDocument, ArrangeRequest, ArrangeResponse, 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 { assertSettingsReadable, orderEdit, planArrange } from './arrange.ts';
15
16
  import { movesChanges, movesOnDisk, moveResponse, movesUnder, planMoves, settingsWithoutRedirectFrom, type ManyMoveRequest } from './move.ts';
16
17
  import { replaceChanges, replaceResponse } from './replace.ts';
17
18
  import { applyWrite, fileVersion, writeAtomic, type WriteContext, type WriteRequest, type WriteResponse } from './write.ts';
@@ -66,6 +67,13 @@ export interface DraftsStore {
66
67
  * autosave, all rolled back on a failure, all going out at the next Publish. Throws WriteRefused.
67
68
  */
68
69
  moveMany(request: ManyMoveRequest, context?: WriteContext): Promise<MoveResponse>;
70
+ /**
71
+ * Arrange pages (Contracts → Apply request): the roots expanded and checked (arrange.ts →
72
+ * `planArrange`), then the moves through `moveMany` with the arranged order as its Site
73
+ * Settings edit, landing or rolling back together; with no moves, Site Settings alone
74
+ * (live mode: under its lock, Decision 21). Throws WriteRefused.
75
+ */
76
+ arrange(request: ArrangeRequest, context?: WriteContext): Promise<ArrangeResponse>;
69
77
  /**
70
78
  * M6c G4 (Decision 9): Replace… on a file. Every link to `from`, in every document, points
71
79
  * at `to` (replace.ts has the rules, the same in both stores). Local: each file written
@@ -125,10 +133,32 @@ export function localDraftsStore(site: Site): DraftsStore {
125
133
  async move(request) {
126
134
  // The file first (a link out of the collection is refused before anything else), then the rules.
127
135
  await confineOnDisk(site, target(site, request.collection, request.path).file);
128
- const moves = movesUnder(site, request, await listDocuments(site));
129
- return moveMany({ moves, ...(request.redirect === false ? { redirect: false } : {}) });
136
+ const documents = await listDocuments(site);
137
+ const moves = movesUnder(site, request, documents);
138
+ // Arrange pages (Decision 6): a saved order follows the move, in the same Site Settings write.
139
+ return moveMany({ moves, ...(request.redirect === false ? { redirect: false } : {}), editSettings: orderEdit(site, moves, documents) });
130
140
  },
131
141
  moveMany,
142
+ async arrange(request) {
143
+ // The files first (a link out of the collection is refused before anything else), then the plan.
144
+ for (const m of request.moves) await confineOnDisk(site, target(site, m.collection, m.fromPath).file);
145
+ const plan = planArrange(site, request, await listDocuments(site));
146
+ // An unreadable Site Settings would drop the order and the redirects without a word: refuse every Apply.
147
+ assertSettingsReadable(site, await diskSource.readText(join(site.root, SITE_SETTINGS_FILE)));
148
+ if (plan.moves.length) {
149
+ await moveMany({ moves: plan.moves, ...(request.redirect === false ? { redirect: false } : {}), editSettings: (text) => plan.edit(text) ?? text });
150
+ return { ok: true };
151
+ }
152
+ const file = join(site.root, SITE_SETTINGS_FILE);
153
+ const before = await diskSource.readText(file);
154
+ assertSettingsReadable(site, before);
155
+ const after = plan.edit(before);
156
+ if (after !== null && after !== before) {
157
+ if (before === null) await writeFile(file, after, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
158
+ else await writeAtomic(file, after);
159
+ }
160
+ return { ok: true };
161
+ },
132
162
  async replaceFile(request) {
133
163
  const changes = await replaceChanges(site, request, await listDocuments(site), (file) => diskSource.readText(join(site.root, file)));
134
164
  // As a local move writes its relinked files: each confined and atomic, in listing order.
@@ -46,7 +46,7 @@ import { documentUrl, type DocumentEntry } from '../../content.ts';
46
46
  import { heldBases, withHeldBases, type HeldBase } from '../../held.ts';
47
47
  import { own, parseAnyDocument, type SettingsDocument } from '../../document.ts';
48
48
  import type { Person } from '../../live/messages.ts';
49
- import { redirectsAddedBy, redirectsAfterMoves, type Redirect } from '../../redirects.ts';
49
+ import { redirectsAddedBy, redirectsAfterMoves, type LiveHome, type Redirect } from '../../redirects.ts';
50
50
  import { relinkText, type AddressMove } from '../../relink.ts';
51
51
  import { schemaRef, stampSchema } from '../../schema-key.ts';
52
52
  import { applyKeyedEdits } from '../../serialize.ts';
@@ -267,14 +267,16 @@ const settingsCollection = (site: Site): ResolvedCollection | undefined => Objec
267
267
  /**
268
268
  * Site Settings' text once its redirects follow every move (redirects.ts →
269
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.
270
+ * chain comes out right; `add` per move; `homes`, live mode's documents whose live address
271
+ * is elsewhere, for a live address a move vacates), its own links are rewritten, and the
272
+ * caller's own `edit` is applied; null when nothing changes. `text` null: there's no Site Settings file yet.
272
273
  */
273
274
  export function settingsAfterMoves(
274
275
  site: Site,
275
276
  text: string | null,
276
277
  moves: readonly { move: AddressMove; add: boolean }[],
277
278
  edit?: (text: string) => string,
279
+ homes: readonly LiveHome[] = [],
278
280
  ): string | null {
279
281
  const collection = settingsCollection(site);
280
282
  if (!collection) return null;
@@ -288,7 +290,7 @@ export function settingsAfterMoves(
288
290
  }
289
291
  }
290
292
  const doc = parseAnyDocument(raw, site, collection) as SettingsDocument;
291
- const redirects = redirectsAfterMoves(doc.data.redirects ?? [], moves.map((m) => ({ from: m.move.fromUrl, to: m.move.toUrl, add: m.add })));
293
+ const redirects = redirectsAfterMoves(doc.data.redirects ?? [], moves.map((m) => ({ from: m.move.fromUrl, to: m.move.toUrl, add: m.add })), homes);
292
294
  const title = relinked === null ? 'Site Settings' : doc.title;
293
295
  let after = applyKeyedEdits(relinked, { title, data: { ...doc.data, redirects } }, collection);
294
296
  if (edit) after = edit(after);
@@ -324,6 +326,14 @@ export function settingsWithoutRedirectFrom(site: Site, text: string | null, url
324
326
  * What the plan changes, from the store's own texts (`readText` by site-relative file;
325
327
  * live mode's has the autosaves on top): each linking file rewritten once for all the
326
328
  * moves that touch it. `addRedirect(plan)`: whether that address gets `old → new`.
329
+ *
330
+ * `liveAddressOf(file)` (live mode, passed only when the move leaves redirects): a
331
+ * document's live address by its file before the plan, when it isn't where the document is
332
+ * (moved since the last Publish; autosaves.ts → `addresses`), else null. When a move
333
+ * vacates such an address and no document takes it, it redirects to where that document
334
+ * is once the plan has landed (redirects.ts → `redirectsAfterMoves`'s `homes`): after a
335
+ * chain A → B, B → C, B's document has no redirect from B (A's sat there), so A's moving
336
+ * on from B leaves `B → C`.
327
337
  */
328
338
  export async function movesChanges(
329
339
  site: Site,
@@ -332,6 +342,7 @@ export async function movesChanges(
332
342
  readText: (file: string) => Promise<string | null>,
333
343
  addRedirect: (plan: MovePlan) => boolean,
334
344
  editSettings?: (text: string) => string,
345
+ liveAddressOf?: (file: string) => string | null,
335
346
  ): Promise<MoveChanges> {
336
347
  const moves = plans.map((p) => p.move);
337
348
  const collectionOf = (name: string) => site.collections[name]!;
@@ -354,23 +365,46 @@ export async function movesChanges(
354
365
  }
355
366
  const before = await readText(SITE_SETTINGS_FILE);
356
367
  const adds = plans.map((p) => ({ move: p.move, add: addRedirect(p) }));
357
- const after = settingsAfterMoves(site, before, adds, editSettings);
368
+ const homes = liveAddressOf ? liveHomes(site, plans, documents, liveAddressOf) : [];
369
+ const after = settingsAfterMoves(site, before, adds, editSettings, homes);
358
370
  const settings = settingsCollection(site);
359
371
  return {
360
372
  moved,
361
373
  relinked,
362
374
  settings: after === null ? null : { before, after },
363
- redirects: redirectsAddedBy(adds.map((a) => ({ from: a.move.fromUrl, to: a.move.toUrl, add: a.add }))),
375
+ redirects: redirectsAddedBy(adds.map((a) => ({ from: a.move.fromUrl, to: a.move.toUrl, add: a.add })), homes),
364
376
  settingsRelinked: !!settings && before !== null && relinkText(before, moves, site, settings).links > 0,
365
377
  };
366
378
  }
367
379
 
380
+ /** Each document with a live address elsewhere (`liveAddressOf`), and its address once the plan has landed. */
381
+ function liveHomes(site: Site, plans: readonly MovePlan[], documents: readonly DocumentEntry[], liveAddressOf: (file: string) => string | null): LiveHome[] {
382
+ const planned = new Map(plans.map((p) => [p.from.file, p]));
383
+ let sited: Site | null = null;
384
+ const homes: LiveHome[] = [];
385
+ for (const d of documents) {
386
+ const live = liveAddressOf(d.file);
387
+ if (live === null) continue;
388
+ const plan = planned.get(d.file);
389
+ let now: string | null;
390
+ if (plan) now = plan.to.url;
391
+ else {
392
+ // Not in the plan: its address doesn't change (a held document whose holder moves is in it).
393
+ sited ??= withHeldBases(site, heldBases(site.collections, documents));
394
+ const collection = sited.collections[d.collection];
395
+ now = collection ? documentUrl(collection, d.path) : null;
396
+ }
397
+ if (now !== null) homes.push({ live, now });
398
+ }
399
+ return homes;
400
+ }
401
+
368
402
  /** The route's answer for a plan done: the first plan's document is the one asked about. */
369
403
  export function moveResponse(plans: readonly MovePlan[], changes: MoveChanges): MoveResponse {
370
404
  const [first] = plans;
371
405
  const relinked = changes.relinked.map(({ collection, path, title }) => ({ collection, path, title }));
372
406
  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;
407
+ const redirectOf = (p: MovePlan) => changes.redirects.find((r) => r.from === p.move.fromUrl && r.to === p.move.toUrl) ?? null;
374
408
  return {
375
409
  ok: true,
376
410
  collection: first!.to.collection,
@@ -0,0 +1,152 @@
1
+ /*
2
+ Arrange pages (notes/2026-10-25-arrange-pages.md; the mockup's notes/mockups/arrange-pages.html, as drawn).
3
+ The screen takes the preview's place as a light sheet over the stage, the way a collection's table does
4
+ (collections.css → .stage-table); the pane holds its level ("Arrange pages", then "Review").
5
+ Rows are the Pages tree's rows grown wide: a grip, the name at its depth (16px steps), the address, the row's end.
6
+ Marks are washes, full rings, chips and labels. No side rails anywhere (M2 Decision 10).
7
+ */
8
+ :root{
9
+ --arr-max-w:940px; /* the tree's width on the stage: names and addresses side by side, left-aligned under its head */
10
+ --arr-row-h:32px; /* a row: a touch taller than the sidebar's 30px, a bigger target to drag and drop on */
11
+ --arr-grip-w:24px; /* the grip's column; a row's icon starts after it, then one --tree-indent per depth */
12
+ --arr-addr-w:300px; /* the address column, at its widest */
13
+ --arr-addr-min:140px; /* the address column at its narrowest (the editor's 1,024px floor), where it gives way to the name first */
14
+ --arr-name-min:200px; /* the name column's narrowest: an icon, a name cut short, and a mark or two */
15
+ --arr-end-w:184px; /* a row's end: its actions (Rename…, Remove), the inside tag, the twisty */
16
+ --arr-mark:2px; /* the beside line's weight; the inside ring is the same 2px, all the way round */
17
+ --arr-ghost-w:300px; /* the dragged card's widest */
18
+ }
19
+ .stage.is-arrange .frame{visibility:hidden}
20
+ .arr{position:absolute;inset:0;z-index:3;display:flex;flex-direction:column;min-height:0;background:var(--surface);color:var(--text);animation:fade-in var(--dur-base) var(--ease-out)}
21
+ .arr-head{flex:none;padding:var(--space-4) var(--space-6) var(--space-3);border-bottom:1px solid var(--border)}
22
+ .arr-head-in{max-width:var(--arr-max-w);display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:var(--space-2) var(--space-3)}
23
+ .arr-title{display:flex;align-items:baseline;gap:var(--space-2);min-width:0}
24
+ .arr-title h2{margin:0;font-size:var(--text-xl);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);white-space:nowrap}
25
+ .arr-title .n-chip{font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-subtle);white-space:nowrap}
26
+ .arr-hint{grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-1-5) var(--space-5);font-size:var(--text-sm);color:var(--text-muted)}
27
+ .arr-ro{display:inline-flex;align-items:center;gap:var(--space-1-5);font-size:var(--text-sm);color:var(--text-muted)}
28
+ /* The two markers' key, drawn as the markers themselves */
29
+ .key{display:inline-flex;align-items:center;gap:var(--space-2);white-space:nowrap}
30
+ .key-line{position:relative;width:26px;height:var(--arr-mark);border-radius:2px;background:var(--select)}
31
+ .key-line::before{content:"";position:absolute;left:-3px;top:50%;width:6px;height:6px;translate:0 -50%;border:2px solid var(--select);border-radius:50%;background:var(--surface)}
32
+ .key-ring{width:26px;height:14px;border-radius:var(--radius-sm);background:var(--select-wash);box-shadow:0 0 0 var(--arr-mark) var(--select)}
33
+ .arr-scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;padding:0 var(--space-6) var(--space-10);scrollbar-width:thin;scrollbar-color:var(--border-hover) transparent}
34
+ .arr-cols{position:sticky;top:0;z-index:2;max-width:var(--arr-max-w);display:grid;grid-template-columns:var(--arr-grip-w) minmax(var(--arr-name-min),1fr) minmax(var(--arr-addr-min),var(--arr-addr-w)) var(--arr-end-w);align-items:center;height:34px;margin-bottom:var(--space-1);background:var(--surface);border-bottom:1px solid var(--border);font-size:var(--text-xs);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-subtle)}
35
+ .arr-tree{position:relative;max-width:var(--arr-max-w)}
36
+ /* A row */
37
+ .arow{position:relative;display:grid;grid-template-columns:var(--arr-grip-w) minmax(var(--arr-name-min),1fr) minmax(var(--arr-addr-min),var(--arr-addr-w)) var(--arr-end-w);align-items:center;min-height:var(--arr-row-h);border:1px solid transparent;border-radius:var(--radius-md);transition:background var(--dur-fast),border-color var(--dur-fast),box-shadow var(--dur-fast)}
38
+ .arow:hover{background:var(--surface-hover)}
39
+ .arow[data-drag]{cursor:grab}
40
+ .ag{display:grid;place-items:center;align-self:stretch;color:var(--icon)}
41
+ .arow[data-drag]:hover .ag{color:var(--text)}
42
+ .an{display:flex;align-items:center;gap:var(--space-2);min-width:0;overflow:hidden;padding-left:calc(var(--depth,0) * var(--tree-indent));padding-right:var(--space-2)}
43
+ .an>.i{flex:none;color:var(--icon)}
44
+ .an .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
45
+ .an .meta{display:inline-flex;flex:0 1 auto;align-items:center;gap:var(--space-1-5);min-width:0;overflow:hidden;font-size:var(--text-xs);color:var(--text-subtle);white-space:nowrap}
46
+ .an .meta .chip{flex:none;height:18px;font-size:var(--text-2xs)}
47
+ .an .meta>:not(.chip){min-width:0;overflow:hidden;text-overflow:ellipsis}
48
+ .an .cnt{font-variant-numeric:tabular-nums}
49
+ .aa{min-width:0;padding-right:var(--space-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:var(--text-xs)/1.3 var(--font-mono);color:var(--text-muted)}
50
+ .aa .new{color:var(--select-strong);font-weight:var(--weight-medium)}
51
+ .aa .was{margin-left:var(--space-2);color:var(--text-subtle);font-family:var(--font-ui)}
52
+ .aa .was .mono{font-family:var(--font-mono)}
53
+ .ae{display:flex;align-items:center;justify-content:flex-end;gap:var(--space-1);min-width:0;padding-right:var(--space-1)}
54
+ .ae .act{opacity:0;transition:opacity var(--dur-fast)}
55
+ .arow:hover .ae .act,.arow:focus-within .ae .act{opacity:1}
56
+ .ae .carried{font-size:var(--text-xs);color:var(--text-subtle);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
57
+ .atw{display:grid;place-items:center;width:calc(var(--tree-twisty) + 6px);height:calc(var(--tree-twisty) + 6px);flex:none;padding:0;border:0;border-radius:var(--radius-sm);background:none;color:var(--icon);transition:background var(--dur-fast),color var(--dur-fast)}
58
+ .atw:hover{background:var(--surface-pressed);color:var(--text)}
59
+ .atw .i{transition:rotate var(--dur-fast) var(--ease-out)}
60
+ .atw[aria-expanded="true"] .i{rotate:90deg}
61
+ .atw-gap{width:calc(var(--tree-twisty) + 6px);flex:none}
62
+ /* Home: fixed, first, takes nothing */
63
+ .arow.is-home .an .nm{font-weight:var(--weight-medium)}
64
+ /* A held collection's row: moves with its page; nothing drags it, nothing drops on it */
65
+ .arow.is-held{color:var(--text-muted)}
66
+ .arow.is-held:hover{background:none}
67
+ /* Marks for what's changed here and not applied yet: the tool's blue in a chip, and the new address in blue */
68
+ .arow.is-gone .an .nm,.arow.is-gone .an>.i{color:var(--text-subtle)}
69
+ .arow.is-gone .aa{color:var(--text-subtle);font-family:var(--font-ui)}
70
+ /* A new group with nothing in it: a full dashed ring (an empty slot, like .empty and .add-row), and its line under it */
71
+ .arow.is-empty{border:1px dashed var(--border-strong)}
72
+ .arow-sub{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-1-5) var(--space-2);min-height:24px;margin:0 0 var(--space-1);padding-left:calc(var(--arr-grip-w) + var(--depth,0) * var(--tree-indent) + 24px);font-size:var(--text-sm);color:var(--text-muted)}
73
+ .arow-sub.err{color:var(--status-danger);font-weight:var(--weight-medium)}
74
+ .arow-sub .i{flex:none}
75
+ /* Naming a new group, or renaming one, in its row */
76
+ .arow.is-naming{background:var(--surface-sunken);border-color:var(--border)}
77
+ .arow.is-naming:hover{background:var(--surface-sunken)}
78
+ .arow.is-naming .an .input{width:min(260px,100%);min-height:26px;height:26px;padding:2px 8px;font-size:var(--text-base)}
79
+ .arow.is-naming .ae .btn{flex:none}
80
+ /* Linked from the review: hovering an entry there washes its row here */
81
+ .arow.is-linked{background:var(--select-wash)}
82
+ /* Dragging. The row being dragged keeps its place as a dashed outline (the block list's ghost, .brow.is-ghost); what's under it goes with it. */
83
+ .arow.is-src{background:none;border:1px dashed var(--border-strong)}
84
+ .arow.is-src>*{visibility:hidden}
85
+ /* Inside: the row it goes into gets the selection's wash and a full 2px ring, and says so at its end */
86
+ .arow.is-into,.arow.is-into:hover{background:var(--select-wash);border-color:var(--select);box-shadow:0 0 0 1px var(--select)}
87
+ .arow.is-into .an .nm{color:var(--select-strong);font-weight:var(--weight-semibold)}
88
+ .arow.is-into .an>.i{color:var(--select-strong)}
89
+ .tag{display:inline-flex;align-items:center;gap:var(--space-1);height:20px;padding:0 var(--space-2);border-radius:var(--radius-pill);background:var(--select);color:var(--on-fill);font-size:var(--text-xs);font-weight:var(--weight-semibold);white-space:nowrap}
90
+ /* Refused: a danger wash and a full thin ring, and the reason in one line across the address and the end */
91
+ .arow.is-refused,.arow.is-refused:hover{background:var(--status-danger-wash);border-color:var(--status-danger-border)}
92
+ .arow-why{grid-column:3 / 5;display:flex;align-items:center;gap:var(--space-1-5);min-width:0;padding-right:var(--space-2);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--status-danger);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
93
+ /* Beside: a 2px line between two rows, starting where that level's icons start (so its indent says which level), a ring at its
94
+ start, and the level it lands in at its end. The line is a marker across the row, never a bar down a card's side. */
95
+ .amark{position:absolute;right:0;z-index:4;height:var(--arr-mark);border-radius:2px;background:var(--select);box-shadow:0 0 0 2px var(--surface);pointer-events:none}
96
+ .amark::before{content:"";position:absolute;left:-4px;top:50%;width:8px;height:8px;translate:0 -50%;border:2px solid var(--select);border-radius:50%;background:var(--surface)}
97
+ .amark .tag{position:absolute;right:var(--space-1);top:50%;translate:0 -50%;box-shadow:0 0 0 2px var(--surface)}
98
+ /* The card under the pointer while dragging */
99
+ .adrag{position:fixed;left:0;top:0;z-index:70;display:grid;grid-template-columns:16px minmax(0,1fr);align-items:center;gap:2px var(--space-2);min-width:220px;max-width:var(--arr-ghost-w);padding:var(--space-2) var(--space-3) var(--space-2) var(--space-2-5);background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius-md);box-shadow:var(--shadow-drag);pointer-events:none;font-size:var(--text-base)}
100
+ .adrag>.i{color:var(--icon)}
101
+ .adrag .nm{display:flex;align-items:baseline;gap:var(--space-1-5);min-width:0;font-weight:var(--weight-semibold);white-space:nowrap;overflow:hidden}
102
+ .adrag .nm small{font-size:var(--text-xs);font-weight:var(--weight-regular);color:var(--text-subtle);overflow:hidden;text-overflow:ellipsis}
103
+ .adrag .to{grid-column:2;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:var(--text-xs)/1.35 var(--font-mono);color:var(--select-strong)}
104
+ .adrag .to.plain{font-family:var(--font-ui);color:var(--text-muted)}
105
+ .adrag.no{border-color:var(--status-danger-border)}
106
+ .adrag.no .to{display:flex;align-items:center;gap:var(--space-1);font-family:var(--font-ui);font-weight:var(--weight-medium);color:var(--status-danger)}
107
+ body.is-dragging,body.is-dragging *{cursor:grabbing!important;user-select:none}
108
+ body.is-dragging.is-no,body.is-dragging.is-no *{cursor:not-allowed!important}
109
+
110
+ /* The pane's levels: Arrange pages, then Review */
111
+ .arr-none{font-size:var(--text-sm);color:var(--text-muted)}
112
+ .chg{list-style:none;margin:0 calc(-1 * var(--space-1));padding:0;display:flex;flex-direction:column;gap:1px}
113
+ .chg li{display:grid;grid-template-columns:16px minmax(0,1fr);gap:var(--space-2);padding:var(--space-1-5) var(--space-1);border-radius:var(--radius-md);font-size:var(--text-sm);color:var(--text-muted);line-height:1.4}
114
+ .chg li .i{margin-top:1px;color:var(--icon)}
115
+ .chg li b{color:var(--text);font-weight:var(--weight-semibold)}
116
+ .chg li.is-muted b{color:var(--text-muted)}
117
+ .arr-foot{flex:none;padding:var(--space-3) var(--space-4) var(--space-4);border-top:1px solid var(--border);background:var(--surface)}
118
+ .arr-foot .why{display:grid;grid-template-columns:16px minmax(0,1fr);gap:var(--space-1-5);margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--text-muted)}
119
+ .arr-foot .why .i{margin-top:1px;color:var(--icon)}
120
+ .arr-foot .why b{color:var(--text);font-weight:var(--weight-semibold)}
121
+ .arr-foot .busy{margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--status-waiting)}
122
+ .arr-foot .acts{display:flex;justify-content:flex-end;gap:var(--space-2)}
123
+ /* The review: what moves, where, and whether old links keep working */
124
+ .rv-lead{margin-bottom:var(--space-4);font-size:var(--text-md);font-weight:var(--weight-semibold);line-height:1.35}
125
+ .rv-lead small{display:block;margin-top:2px;font-size:var(--text-sm);font-weight:var(--weight-regular);color:var(--text-muted)}
126
+ .rv{list-style:none;margin:0 calc(-1 * var(--space-1));padding:0;display:flex;flex-direction:column;gap:1px}
127
+ .rv-e{display:grid;grid-template-columns:16px minmax(0,1fr);column-gap:var(--space-2);row-gap:1px;margin-left:calc(var(--in,0) * var(--tree-indent));padding:var(--space-1-5) var(--space-1);border-radius:var(--radius-md);transition:background var(--dur-fast)}
128
+ .rv-e:hover{background:var(--surface-hover)}
129
+ .rv-e>.i{margin-top:1px;color:var(--icon)}
130
+ .rv-e .t{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-1) var(--space-1-5);min-width:0;font-weight:var(--weight-semibold);line-height:1.35}
131
+ .rv-e .t .chip{height:18px;font-size:var(--text-2xs)}
132
+ .rv-e.is-carried .t,.rv-e.is-held .t{font-weight:var(--weight-medium)}
133
+ .rv-e.is-gone .t{font-weight:var(--weight-medium);color:var(--text-muted)}
134
+ .rv-e.is-gone>.i{color:var(--text-subtle)}
135
+ .mv{grid-column:2;display:flex;flex-wrap:wrap;column-gap:var(--space-1);min-width:0;font:var(--text-xs)/1.5 var(--font-mono);color:var(--text-subtle);overflow-wrap:anywhere}
136
+ .mv .to{color:var(--text)}
137
+ .rv-more{margin-left:auto;font-size:var(--text-xs)}
138
+ .rv-more .i{transition:rotate var(--dur-fast) var(--ease-out)}
139
+ .rv-more[aria-expanded="true"] .i{rotate:90deg}
140
+ .rv-docs{grid-column:2;list-style:none;margin:var(--space-1-5) 0 0;padding:var(--space-1-5) var(--space-2);border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface-sunken);font:var(--text-xs)/1.6 var(--font-mono);color:var(--text-muted);overflow-wrap:anywhere}
141
+ .rv-docs li.is-new{color:var(--status-warning-strong);font-weight:var(--weight-medium)}
142
+ .rv-tick{margin-top:var(--space-4);padding-top:var(--space-4);border-top:1px solid var(--border)}
143
+ .rv-tick .field-help{margin-bottom:0}
144
+ /* Shared pieces the mockup draws outside a dialog or a level's own CSS: the tick, and the order-only panel's fixed line */
145
+ .rv-tick .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}
146
+ .rv-tick .check input{flex:none;width:16px;height:16px;margin:0;accent-color:var(--select);cursor:inherit}
147
+ .rv-tick .check + .field-help{margin-top:var(--space-1);padding-left:calc(16px + var(--space-2))}
148
+ .rv-tick .check:has(input:disabled){cursor:not-allowed;color:var(--text-muted)}
149
+ .side-level[data-pane-level="arrange"] .sec-h .n{font-weight:var(--weight-medium);letter-spacing:0;color:var(--text-subtle)}
150
+ .arow.is-naming .input[aria-invalid="true"]{border-color:var(--status-danger);box-shadow:0 0 0 3px var(--status-danger-halo)}
151
+ .arr-foot .progress{margin:0 0 var(--space-2-5)}
152
+ .mv .fr{margin-top:0}