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
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Arrange pages (notes/2026-10-25-arrange-pages.md): the screen's state and actions, one
3
+ * per editor (`arrangeFor(editor)`), read by the stage (ArrangeStage.tsx) and the pane's
4
+ * level (pane/levels/ArrangeLevel.tsx). Nothing is saved until Apply (Decision 15): the
5
+ * state starts again from the listing each time the screen opens, and a reload does too.
6
+ *
7
+ * arrange.state { mode, orig, tree, drag, naming, renaming, collapsed, … }
8
+ * arrange.plan() what it means (model.ts → makePlan), worked out once per tree
9
+ * arrange.drop(id, t) / newGroup() / rename… / remove(id)
10
+ * arrange.review() / back() / apply()
11
+ * arrange.leave(go?) asks first when there are changes (the leave dialog), then goes
12
+ *
13
+ * Apply (Decision 10): this tab's unsent edits to what moves go first (as Change address…),
14
+ * then one POST of the plan's request. A refusal re-reads the listing, re-plans the same
15
+ * arrangement on it and shows the review again, naming what changed; a lock refusal names
16
+ * the person and the document.
17
+ */
18
+ import { isOrderedCollection, listingStamp } from '../../../page-order.ts';
19
+ import { SETTINGS_PATH } from '../../../reserved.ts';
20
+ import type { Editor } from '../editor.ts';
21
+ import { closeDialog, openDialog } from '../documents/dialogs.tsx';
22
+ import { createStore, type Store } from '../state/store.ts';
23
+ import { docId, ENDPOINTS, TAB_HEADER, type ArrangeRequest, type DocRef, type LockRefusal, type Person } from '../../types.ts';
24
+ import { addGroup, buildTree, dropNode, indexTree, locate, makePlan, nameCheck, rebase, removeNode, renameGroup, type ArrangeNode, type ArrangeSite, type DropTarget, type Plan } from './model.ts';
25
+
26
+ export type ArrangeNote =
27
+ | { kind: 'lock'; person: Person | null; error: string }
28
+ | { kind: 'underneath'; lines: string[]; clashes: string[] }
29
+ | { kind: 'refused'; error: string }
30
+ | { kind: 'failed'; error: string };
31
+
32
+ export interface ArrangeState {
33
+ /** Arranging, the review, or the review while Apply runs. */
34
+ mode: 'arrange' | 'review' | 'applying';
35
+ /** The tree as the listing had it when the screen opened (or at the last re-plan), and as arranged. */
36
+ orig: ArrangeNode[];
37
+ tree: ArrangeNode[];
38
+ /** Mid-drag: the row and where it would land (null: nowhere). */
39
+ drag: { id: string; target: DropTarget | null } | null;
40
+ /** New group, being named; Rename… on a group, inline. */
41
+ naming: { value: string } | null;
42
+ renaming: { id: string; value: string } | null;
43
+ /** Rows whose rows under them are hidden (the screen's twisties). */
44
+ collapsed: string[];
45
+ /** Holders whose documents the review shows one by one. */
46
+ openHeld: string[];
47
+ /** "Leave redirects from the old addresses" (Decision 3). */
48
+ tick: boolean;
49
+ note: ArrangeNote | null;
50
+ /** After a re-plan: rows and held documents added since the screen opened. */
51
+ added: string[];
52
+ addedDocs: string[];
53
+ /** New groups made so far (their ids). */
54
+ made: number;
55
+ /** The row an entry of the review points at while hovered (washed on the screen). */
56
+ linked: string | null;
57
+ }
58
+
59
+ export interface Arrange {
60
+ state: Store<ArrangeState>;
61
+ site: ArrangeSite;
62
+ plan(): Plan;
63
+ /** Why Review… is off, as text and bold parts in turn; null when it's on. */
64
+ blocked(): string[] | null;
65
+ reset(): void;
66
+ setDrag(drag: ArrangeState['drag']): void;
67
+ drop(id: string, target: DropTarget): void;
68
+ newGroup(): void;
69
+ setNaming(value: string | null): void;
70
+ confirmNaming(): void;
71
+ startRename(id: string): void;
72
+ setRenaming(value: string | null): void;
73
+ confirmRename(): void;
74
+ remove(id: string): void;
75
+ toggleCollapsed(id: string): void;
76
+ toggleHeld(id: string): void;
77
+ setLinked(id: string | null): void;
78
+ setTick(tick: boolean): void;
79
+ review(): void;
80
+ back(): void;
81
+ apply(): Promise<void>;
82
+ /** Leave the screen (Cancel, Back, the trail): asks first when anything is arranged. */
83
+ leave(go?: () => void): void;
84
+ }
85
+
86
+ const all = new WeakMap<Editor, Arrange>();
87
+ const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x)) as T;
88
+
89
+ /** The collection the screen arranges (`pages`): the one the saved order is for, by the server's rule (page-order.ts). */
90
+ export function arrangeSite(editor: Pick<Editor, 'site'>): ArrangeSite {
91
+ const pages = editor.site.collections.find(isOrderedCollection);
92
+ return {
93
+ collection: pages?.name ?? 'pages',
94
+ base: pages?.urlBase ?? '/',
95
+ home: editor.site.home.collection === pages?.name ? editor.site.home.path : 'index',
96
+ collections: editor.site.collections,
97
+ codeRoutes: editor.site.codeRoutes,
98
+ };
99
+ }
100
+
101
+ export function arrangeFor(editor: Editor): Arrange {
102
+ const had = all.get(editor);
103
+ if (had) return had;
104
+ const site = arrangeSite(editor);
105
+ const start = (): ArrangeState => {
106
+ const orig = buildTree(site, editor.documents.get());
107
+ return { mode: 'arrange', orig, tree: clone(orig), drag: null, naming: null, renaming: null, collapsed: [], openHeld: [], tick: true, note: null, added: [], addedDocs: [], made: 0, linked: null };
108
+ };
109
+ const state = createStore<ArrangeState>(start());
110
+ let cached: { orig: ArrangeNode[]; tree: ArrangeNode[]; waiting: unknown; plan: Plan } | null = null;
111
+ const plan = () => {
112
+ const { orig, tree } = state.get();
113
+ const waiting = editor.liveState.get().status?.waiting;
114
+ if (cached && cached.orig === orig && cached.tree === tree && cached.waiting === waiting) return cached.plan;
115
+ // Made since the last Publish, or moved since and already redirected: no live address there to leave a redirect from.
116
+ const pending = (collection: string, path: string) => (waiting ?? []).some((w) => w.collection === collection && w.path === path && (w.change === 'new' || !!w.movedFrom));
117
+ cached = { orig, tree, waiting, plan: makePlan(site, orig, tree, { pending }) };
118
+ return cached.plan;
119
+ };
120
+ /** A change to the tree, on a copy (the plan is worked out again from it). */
121
+ const change = (edit: (tree: ArrangeNode[]) => void, patch: Partial<ArrangeState> = {}) => {
122
+ const tree = clone(state.get().tree);
123
+ edit(tree);
124
+ state.update({ tree, ...patch });
125
+ };
126
+ const parentOf = (id: string) => locate(state.get().tree, id)?.parentId ?? null;
127
+ const goToDocuments = () => {
128
+ void editor.openDocument(editor.doc.get().ref, { history: 'push' }).then(() => editor.goTo('documents'));
129
+ };
130
+
131
+ const arrange: Arrange = {
132
+ state,
133
+ site,
134
+ plan,
135
+ blocked() {
136
+ const s = state.get();
137
+ if (s.naming) return ['Finish naming the new group first.'];
138
+ if (s.renaming) return [`Finish renaming ${locate(s.tree, s.renaming.id)?.node.label ?? 'the group'} first.`];
139
+ const p = plan();
140
+ if (p.emptyFresh.length) return ['', p.emptyFresh[0]!.label, ' is empty: add a page to it or remove it.'];
141
+ if (!p.any) return ['Nothing to review yet.'];
142
+ return null;
143
+ },
144
+ reset() {
145
+ cached = null;
146
+ state.set(start());
147
+ },
148
+ setDrag(drag) {
149
+ state.update({ drag });
150
+ },
151
+ drop(id, target) {
152
+ if (target.kind === 'refused') return state.update({ drag: null });
153
+ change((tree) => dropNode(tree, id, target), { drag: null });
154
+ },
155
+ newGroup() {
156
+ state.update({ naming: { value: '' }, renaming: null });
157
+ },
158
+ setNaming(value) {
159
+ state.update({ naming: value === null ? null : { value } });
160
+ },
161
+ confirmNaming() {
162
+ const s = state.get();
163
+ if (!s.naming) return;
164
+ const c = nameCheck(site, s.tree, s.naming.value, null, null);
165
+ if (c.bad) return;
166
+ // A group named like a page at the top level is that page (Decision 8): nothing to add.
167
+ if (c.merge) return state.update({ naming: null });
168
+ change((tree) => addGroup(tree, s.naming!.value, s.made + 1), { naming: null, made: s.made + 1 });
169
+ },
170
+ startRename(id) {
171
+ const node = locate(state.get().tree, id)?.node;
172
+ if (node) state.update({ renaming: { id, value: node.label }, naming: null });
173
+ },
174
+ setRenaming(value) {
175
+ const r = state.get().renaming;
176
+ state.update({ renaming: value === null || !r ? null : { id: r.id, value } });
177
+ },
178
+ confirmRename() {
179
+ const s = state.get();
180
+ if (!s.renaming) return;
181
+ const { id, value } = s.renaming;
182
+ const c = nameCheck(site, s.tree, value, parentOf(id), id);
183
+ if (c.bad) return;
184
+ change((tree) => renameGroup(tree, id, value, c.merge ?? null), { renaming: null });
185
+ },
186
+ remove(id) {
187
+ change((tree) => removeNode(tree, id));
188
+ },
189
+ toggleCollapsed(id) {
190
+ const c = state.get().collapsed;
191
+ state.update({ collapsed: c.includes(id) ? c.filter((x) => x !== id) : [...c, id] });
192
+ },
193
+ toggleHeld(id) {
194
+ const o = state.get().openHeld;
195
+ state.update({ openHeld: o.includes(id) ? o.filter((x) => x !== id) : [...o, id] });
196
+ },
197
+ setLinked(linked) {
198
+ state.update({ linked });
199
+ },
200
+ setTick(tick) {
201
+ state.update({ tick });
202
+ },
203
+ review() {
204
+ if (arrange.blocked()) return;
205
+ state.update({ mode: 'review', note: null, drag: null });
206
+ },
207
+ back() {
208
+ if (state.get().mode === 'applying') return;
209
+ state.update({ mode: 'arrange', note: null, added: [], addedDocs: [] });
210
+ },
211
+ async apply() {
212
+ const s = state.get();
213
+ const p = plan();
214
+ if (s.mode !== 'review' || (s.note?.kind === 'underneath' && s.note.clashes.length)) return;
215
+ state.update({ mode: 'applying', note: null });
216
+ const refused = (note: ArrangeNote) => state.update({ mode: 'review', note });
217
+ // This tab's unsent edits to what moves (and to Site Settings, which the order rides) go first.
218
+ const refs: DocRef[] = [...movingRefs(site, p), { collection: 'settings', path: SETTINGS_PATH }];
219
+ for (const r of refs) {
220
+ const id = docId(r);
221
+ let flushed = true;
222
+ await editor.keeper?.flush(id).catch(() => (flushed = false));
223
+ if (!flushed || editor.keeper?.dirty(id)) {
224
+ const what = editor.documents.get().find((d) => d.collection === r.collection && d.path === r.path)?.label ?? r.path;
225
+ return refused({ kind: 'refused', error: `${what} has changes that haven't been saved yet, so nothing can move. Sort those out first (Use mine or Discard mine, or sign in again), then apply again.` });
226
+ }
227
+ }
228
+ const body: ArrangeRequest = { moves: p.moves, order: p.order, expect: expected(site, p), ...(p.live && !s.tick ? { redirect: false } : {}) };
229
+ const answer = await post(editor, body);
230
+ if (!answer.ok) {
231
+ if (answer.code === 'locked') return refused({ kind: 'lock', person: answer.lock?.person ?? null, error: answer.error });
232
+ if (answer.status === 0 || answer.status >= 500 || answer.status === 401) return refused({ kind: 'failed', error: answer.error });
233
+ // Something changed underneath (Decision 10): the same arrangement on the listing as it is now.
234
+ await editor.refreshDocuments().catch(() => undefined);
235
+ const now = state.get();
236
+ const r = rebase(site, now.orig, now.tree, editor.documents.get());
237
+ const holders = [...indexTree(r.tree).values()].filter((x) => x.node.holds?.docs.some((d) => r.addedDocs.includes(d.path))).map((x) => x.node.id);
238
+ state.update({ orig: r.orig, tree: r.tree, added: r.added, addedDocs: r.addedDocs, openHeld: [...new Set([...now.openHeld, ...holders])] });
239
+ return refused(r.notes.length || r.clashes.length ? { kind: 'underneath', lines: r.notes, clashes: r.clashes } : { kind: 'refused', error: answer.error });
240
+ }
241
+ for (const r of refs) editor.keeper?.discard(docId(r));
242
+ await editor.refreshDocuments().catch(() => undefined);
243
+ // The document under the screen goes with its page when it moved.
244
+ const open = editor.doc.get().ref;
245
+ const to = open.collection === site.collection ? newPathOf(p, open.path) : null;
246
+ const total = p.total;
247
+ const later = editor.mode === 'live' ? (total === 1 ? ' It goes live at the next Publish.' : ' They go live at the next Publish.') : '';
248
+ const text = total ? `Applied: ${p.lead} ${total === 1 ? 'has a new address' : 'have new addresses'}.${later}` : 'Applied: the Pages tree’s new order is saved.';
249
+ arrange.reset();
250
+ await editor.openDocument(to ? { collection: open.collection, path: to } : open, { history: 'push' });
251
+ editor.goTo('documents');
252
+ editor.toast(text, 'check');
253
+ editor.announce(text);
254
+ },
255
+ leave(go = goToDocuments) {
256
+ if (state.get().mode === 'applying') return;
257
+ const proceed = () => {
258
+ arrange.reset();
259
+ go();
260
+ };
261
+ if (!plan().any) return proceed();
262
+ openDialog(editor, {
263
+ kind: 'arrange-leave',
264
+ go: () => {
265
+ closeDialog(editor);
266
+ proceed();
267
+ },
268
+ });
269
+ },
270
+ };
271
+ editor.arrangeGuard = {
272
+ ask(go) {
273
+ if (editor.view.get().level !== 'arrange' || !plan().any) {
274
+ if (editor.view.get().level === 'arrange') arrange.reset();
275
+ return false;
276
+ }
277
+ arrange.leave(go);
278
+ return true;
279
+ },
280
+ up() {
281
+ const s = state.get();
282
+ if (s.naming || s.renaming) return;
283
+ if (s.mode === 'review') arrange.back();
284
+ else if (s.mode === 'arrange') arrange.leave();
285
+ },
286
+ };
287
+ // Each time the screen opens, it starts from the listing as it is (Decision 15).
288
+ editor.view.subscribe((next, previous) => {
289
+ if (next.level === 'arrange' && previous.level !== 'arrange') arrange.reset();
290
+ });
291
+ all.set(editor, arrange);
292
+ return arrange;
293
+ }
294
+
295
+ /** Every document whose address the plan changes, by its path now: the pages, and the held documents that go with them. */
296
+ function movingRefs(site: ArrangeSite, p: Plan): DocRef[] {
297
+ const out: DocRef[] = [];
298
+ for (const id of p.moved) out.push({ collection: site.collection, path: p.orig.get(id)!.path });
299
+ for (const h of p.heldMoved) for (const d of h.docs) out.push({ collection: h.collection, path: d.path });
300
+ return out;
301
+ }
302
+
303
+ /**
304
+ * Decision 10: the listing the review was planned on, as the server checks it at Apply
305
+ * (`ArrangeRequest.expect`): every page's path, and each moving holder's documents'.
306
+ */
307
+ function expected(site: ArrangeSite, p: Plan): Record<string, string> {
308
+ const pages = [...p.orig.values()].filter((x) => x.node.kind === 'page').map((x) => x.path);
309
+ const out: Record<string, string> = { [site.collection]: listingStamp(pages) };
310
+ for (const h of p.heldMoved) out[h.collection] = listingStamp(h.docs.map((d) => d.path));
311
+ return out;
312
+ }
313
+
314
+ /** Where a page of the arranged collection is after the plan (its path now, if it moves). */
315
+ function newPathOf(p: Plan, path: string): string | null {
316
+ for (const [id, y] of p.orig) if (y.path === path && p.now.has(id) && p.now.get(id)!.path !== path) return p.now.get(id)!.path;
317
+ return null;
318
+ }
319
+
320
+ type Answer = { ok: true } | { ok: false; status: number; error: string; code: string | null; lock: LockRefusal['lock'] | null };
321
+
322
+ async function post(editor: Editor, body: ArrangeRequest): Promise<Answer> {
323
+ const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
324
+ if (editor.liveSession) headers[TAB_HEADER] = editor.liveSession.tab;
325
+ let response: Response;
326
+ try {
327
+ response = await fetch(ENDPOINTS.arrange, { method: 'POST', headers, body: JSON.stringify(body) });
328
+ } catch {
329
+ return { ok: false, status: 0, error: "The server can't be reached. Nothing was applied; try again in a moment.", code: 'offline', lock: null };
330
+ }
331
+ const said = (await response.json().catch(() => ({}))) as Partial<LockRefusal> & { error?: string; code?: string };
332
+ if (response.status === 401 && editor.liveSession) editor.liveState.update({ signedOut: said as never });
333
+ if (!response.ok) return { ok: false, status: response.status, error: said.error ?? `The server said no (${response.status}).`, code: said.code ?? null, lock: said.lock ?? null };
334
+ return { ok: true };
335
+ }
@@ -28,7 +28,7 @@ import { useEditor, useStore } from '../context.tsx';
28
28
  import type { Editor } from '../editor.ts';
29
29
  import { createStore, type Store } from '../state/store.ts';
30
30
  import { Icon } from '../shell/Icons.tsx';
31
- import { docIcon } from '../nav/DocRow.tsx';
31
+ import { docIcon, hiddenTitle } from '../nav/DocRow.tsx';
32
32
  import { DocMarksView } from '../live/marks.tsx';
33
33
  import { when } from '../live/format.ts';
34
34
  import { openDialog } from '../documents/dialogs.tsx';
@@ -378,6 +378,14 @@ function Row(props: { doc: ListedDocument; columns: readonly TableColumn[]; curr
378
378
  <span className="sr">, can't be opened: {doc.warning.message}</span>
379
379
  </span>
380
380
  ) : null}
381
+ {/* M9 Decision 7, collections under pages (Decision 11): hidden, or hidden with the page that holds it, named. */}
382
+ {/* The sidebar's mark: the word, small and grey; the page that hides it on hover and to a screen reader. */}
383
+ {doc.hidden ? (
384
+ <span className="hmark" data-mark="hidden" title={hiddenTitle(doc, editor.documents.get())}>
385
+ Hidden
386
+ {doc.hiddenBy ? <span className="sr">: {hiddenTitle(doc, editor.documents.get())}</span> : null}
387
+ </span>
388
+ ) : null}
381
389
  {editor.mode === 'live' ? <DocMarksView file={doc.file} /> : <span data-slot="presence" hidden />}
382
390
  </span>
383
391
  {props.extra ? <span className="ct-extra">{props.extra}</span> : null}
@@ -70,31 +70,76 @@ export async function deleteDocument(editor: Editor, ref: DocRef, title: string)
70
70
  /**
71
71
  * Change a document's address (pre-M7 item 8). Its unsent edits go first (the move
72
72
  * carries them), then the document opens at its new address in place of the old one,
73
- * and the list is read again (other documents' links changed too).
73
+ * and the list is read again (other documents' links changed too). `redirect` false: no
74
+ * redirect from the old address.
74
75
  */
75
- export async function moveDocument(editor: Editor, ref: DocRef, to: string, title: string): Promise<{ ok: true; body: MoveResponse } | Refused> {
76
- const id = docId(ref);
77
- const open = sameDoc(editor.doc.get().ref, ref);
76
+ export async function moveDocument(editor: Editor, ref: DocRef, to: string, title: string, redirect = true): Promise<{ ok: true; body: MoveResponse } | Refused> {
77
+ // Everything under it moves with it (collections under pages, Decision 9): each of those documents' unsent edits too.
78
+ const moving = addressesMoving(editor.site?.collections ?? [], ref, to, editor.documents?.get() ?? []).map((m) => ({ collection: m.doc.collection, path: m.doc.path }));
79
+ const refs = moving.some((m) => sameDoc(m, ref)) ? moving : [ref, ...moving];
78
80
  // Nothing moves while the keeper still holds edits the server hasn't got (a held conflict, a
79
81
  // signed-out session, a refused write): the move would discard them.
80
- let flushed = true;
81
- await editor.keeper?.flush(id).catch(() => {
82
- flushed = false;
83
- });
84
- if (!flushed || editor.keeper?.dirty(id)) {
85
- return { ok: false, status: 0, error: `${title} has changes that haven't been saved yet, so its address can't change. Sort those out first (Use mine or Discard mine, or sign in again), then try again.`, code: 'unsent', usedOn: null };
82
+ for (const r of refs) {
83
+ const id = docId(r);
84
+ let flushed = true;
85
+ await editor.keeper?.flush(id).catch(() => {
86
+ flushed = false;
87
+ });
88
+ if (!flushed || editor.keeper?.dirty(id)) {
89
+ const what = sameDoc(r, ref) ? title : (editor.documents?.get().find((d) => sameDoc(d, r))?.label ?? r.path);
90
+ return { ok: false, status: 0, error: `${what} has changes that haven't been saved yet, so ${sameDoc(r, ref) ? 'its' : `${title}'s`} address can't change. Sort those out first (Use mine or Discard mine, or sign in again), then try again.`, code: 'unsent', usedOn: null };
91
+ }
86
92
  }
87
- const answer = await post<MoveResponse>(editor, ENDPOINTS.move, { collection: ref.collection, path: ref.path, to });
93
+ const answer = await post<MoveResponse>(editor, ENDPOINTS.move, { collection: ref.collection, path: ref.path, to, ...(redirect ? {} : { redirect: false }) });
88
94
  if (!answer.ok) return answer;
89
- editor.keeper?.discard(id);
90
- await editor.refreshDocuments().catch(() => undefined);
91
- if (open) await editor.openDocument({ collection: answer.body.collection, path: answer.body.path }, { history: 'replace' });
92
95
  const { url, from } = answer.body;
93
- editor.toast(`Moved ${title} to ${url}.`, 'check');
94
- editor.announce(`Moved ${title} from ${from.url} to ${url}.`);
96
+ const moved = answer.body.moved ?? [];
97
+ for (const r of refs) editor.keeper?.discard(docId(r));
98
+ await editor.refreshDocuments().catch(() => undefined);
99
+ // The open document goes with it when it's under it.
100
+ const openRef = editor.doc.get().ref;
101
+ const opened = sameDoc(openRef, ref) ? { collection: answer.body.collection, from: ref.path, to: answer.body.path } : moved.find((m) => m.collection === openRef.collection && m.from === openRef.path);
102
+ if (opened && opened.from !== opened.to) await editor.openDocument({ collection: opened.collection, path: opened.to }, { history: 'replace' });
103
+ else if (opened) await editor.reloadOpen();
104
+ const more = Math.max(moved.length - 1, 0);
105
+ editor.toast(more ? `Moved ${title} to ${url}, with ${more} more under it.` : `Moved ${title} to ${url}.`, 'check');
106
+ editor.announce(more ? `Moved ${title} from ${from.url} to ${url}, and ${more} more addresses under it.` : `Moved ${title} from ${from.url} to ${url}.`);
95
107
  return answer;
96
108
  }
97
109
 
110
+ /** One address Change address… changes (collections under pages, Decision 9). */
111
+ export interface AddressChange {
112
+ doc: ListedDocument;
113
+ /** Its new address; null while the new path isn't one yet. */
114
+ toUrl: string | null;
115
+ /** A document of a collection a page in the move holds (its file stays; only its address changes). */
116
+ held: boolean;
117
+ }
118
+
119
+ /**
120
+ * Every address Change address… on `ref` changes once it's at `to` (Decision 9): the
121
+ * document, the pages under it at any depth, and the documents of each collection held by
122
+ * it or a page under it. move.ts → `movesUnder` and `planMoves` work out the same, so the
123
+ * dialog lists them before asking.
124
+ */
125
+ export function addressesMoving(collections: readonly EditorAnyCollection[], ref: DocRef, to: string, documents: readonly ListedDocument[]): AddressChange[] {
126
+ const collection = collections.find((c) => c.name === ref.collection);
127
+ const root = documents.find((d) => d.collection === ref.collection && d.path === ref.path);
128
+ if (!collection || !root) return [];
129
+ const newPath = (path: string) => (path === ref.path ? to : `${to}${path.slice(ref.path.length)}`);
130
+ const pages = [root, ...(collection.nested ? pagesUnder(collection.name, ref.path, documents) : [])];
131
+ const out: AddressChange[] = pages.map((d) => ({ doc: d, toUrl: addressFor(collection, newPath(d.path)), held: false }));
132
+ for (const held of collections) {
133
+ const holder = held.holder;
134
+ if (!held.heldBy || !holder || holder.collection !== ref.collection) continue;
135
+ if (holder.path !== ref.path && !holder.path.startsWith(`${ref.path}/`)) continue;
136
+ const holderUrl = addressFor(collection, newPath(holder.path));
137
+ const base = holderUrl === null ? null : `${holderUrl.replace(/\/+$/, '')}/`;
138
+ for (const d of documents) if (d.collection === held.name && d.url !== null && !d.warning) out.push({ doc: d, toUrl: base === null ? null : base + d.path, held: true });
139
+ }
140
+ return out;
141
+ }
142
+
98
143
  /**
99
144
  * M6c G4 (Decision 9): Replace… on a file. Every unsent edit in this tab goes first, and
100
145
  * nothing is replaced while any is still held (a conflict, a refusal, a signed-out
@@ -167,8 +212,8 @@ export interface InsideOption {
167
212
  * New page's "Inside" (Decision 11): the top level, then every group and every page, at
168
213
  * any depth, in tree order. A page with nothing under it yet is offered too, so a page can
169
214
  * go under it (Urban Return, 2026-10-24); Home isn't, since no address goes under `/index/`.
170
- * At each level, what already holds pages comes first (as folders come first in a file
171
- * list), so the site's shape is at the top while the rest are a scroll or a search away.
215
+ * At each level, the Pages tree's order (arrange pages, Decision 19): `documents` is the
216
+ * listing, already in that order (Home, the saved order, then what holds pages, then A–Z).
172
217
  */
173
218
  export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'urlBase'>, documents: readonly ListedDocument[]): InsideOption[] {
174
219
  const base = collection.urlBase ?? '/';
@@ -182,11 +227,22 @@ export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'ur
182
227
  const labelOf = (path: string) => docs.find((d) => d.path === path)?.label ?? titleCase(path.split('/').pop()!);
183
228
  const holds = (path: string) => docs.some((d) => d.path.startsWith(`${path}/`));
184
229
  const parentOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
230
+ // Arrange pages (Decision 19): the tree's order, as the listing gives it (content.ts → pageTree,
231
+ // page-order.ts's rule): a folder sits where its page, or its first page down, is listed.
232
+ const first = new Map<string, number>();
233
+ docs.forEach((d, i) => {
234
+ const parts = d.path.split('/');
235
+ for (let n = 1; n <= parts.length; n++) {
236
+ const at = parts.slice(0, n).join('/');
237
+ if (!first.has(at)) first.set(at, i);
238
+ }
239
+ });
240
+ const rank = (path: string) => first.get(path) ?? Number.MAX_SAFE_INTEGER;
185
241
  const children = new Map<string, string[]>();
186
242
  for (const f of folders) children.set(parentOf(f), [...(children.get(parentOf(f)) ?? []), f]);
187
243
  const out: InsideOption[] = [{ path: '', label: 'Top level', name: 'Top level', depth: 0, page: false, holds: true, base }];
188
244
  const walk = (parent: string) => {
189
- const kids = (children.get(parent) ?? []).sort((a, b) => Number(holds(b)) - Number(holds(a)) || a.localeCompare(b));
245
+ const kids = (children.get(parent) ?? []).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
190
246
  for (const path of kids) {
191
247
  const parts = path.split('/');
192
248
  out.push({