caracms 0.4.0 → 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.
@@ -186,6 +186,11 @@ export interface AdminProps {
186
186
  * document's address. `opened` is then Home, the page under the inbox.
187
187
  */
188
188
  inbox?: { id: string | null } | null;
189
+ /**
190
+ * Arrange pages (Decision 15): the address opened the arrange screen (`/admin/arrange`),
191
+ * with nothing arranged. `opened` is then Home, the page under it.
192
+ */
193
+ arrange?: boolean;
189
194
  /**
190
195
  * M6 Decision 9: the unread count for the Site-wide row, read as /admin renders; or,
191
196
  * when the production server has no submissions bucket, the sentence naming what's
@@ -281,6 +286,8 @@ export const ENDPOINTS = {
281
286
  delete: '/_caracms/delete',
282
287
  // Pre-M7 item 8: changing a document's address, both modes.
283
288
  move: '/_caracms/move',
289
+ // Arrange pages (Contracts): the arrange screen's Apply, both modes.
290
+ arrange: '/_caracms/arrange',
284
291
  // M5b Phase 2: the media routes, both modes (MEDIA_ENDPOINTS below has each one).
285
292
  media: '/_caracms/media',
286
293
  // M4 Phase 2 (live mode only; local mode answers 404).
@@ -506,6 +513,12 @@ export interface StatusResponse {
506
513
  * an autosave), so the chrome picks up a change on the next poll with no reload.
507
514
  */
508
515
  brand: EditorBrand;
516
+ /**
517
+ * Arrange pages (Decision 18): a short hash of Site Settings' `pageOrder` as it stands in
518
+ * the store (the autosave on top of `drafts`); `""` when there's none. When it changes,
519
+ * the tab re-reads the listing, as it does when the waiting list's keys change.
520
+ */
521
+ pageOrderStamp: string;
509
522
  }
510
523
 
511
524
  /** POST /_caracms/lock `{ file, action }`. */
@@ -629,6 +642,39 @@ export interface MoveResponse {
629
642
  redirects: { from: string; to: string }[];
630
643
  }
631
644
 
645
+ /**
646
+ * POST /_caracms/arrange (arrange pages, Contracts): the arrangement as its result.
647
+ * - `moves`: the roots only, each page whose path changes and isn't carried by a root above
648
+ * it (its old path under that root's `fromPath`, its new path that root's `toPath` + the
649
+ * same rest). The server expands each to everything under it (pages at any depth, a held
650
+ * collection's documents) except what's under another root, which wins for its own subtree.
651
+ * - `order`: the full child list for every parent whose children or order changed, keyed by
652
+ * the parent's path after the moves (`""` the top level); an empty list drops the key.
653
+ * Every other saved list is re-keyed after the moves by the server.
654
+ * - `redirect: false`: no redirects from the old addresses (the review's tick unticked).
655
+ */
656
+ export interface ArrangeRequest {
657
+ moves: ArrangeMove[];
658
+ order: Record<string, string[]>;
659
+ redirect?: boolean;
660
+ /**
661
+ * Collection name → page-order.ts's `listingStamp` of every document path in that
662
+ * collection (hidden ones included) as the review's listing had them: Pages, and each
663
+ * collection held by a page that moves. Any difference at Apply refuses with 409
664
+ * `changed` and nothing is written (Decision 10). Absent: no check.
665
+ */
666
+ expect?: Record<string, string>;
667
+ }
668
+ export interface ArrangeMove {
669
+ collection: string;
670
+ fromPath: string;
671
+ toPath: string;
672
+ }
673
+ /** 200: the moves and the order landed together (live mode: as autosaves, out at the next Publish). Refused: an ApiError like the move route's (`{ error, code }`, a lock refusal's state with `locked`). */
674
+ export interface ArrangeResponse {
675
+ ok: true;
676
+ }
677
+
632
678
  /**
633
679
  * Why a create or delete was refused: `{ error, code }` with a status.
634
680
  * - 400 `bad-request` (the body), `bad-path` (not a document path, outside its
@@ -639,7 +685,7 @@ export interface MoveResponse {
639
685
  * block with no or an unknown block type), `not-held` (collections under pages, Decision 7: a
640
686
  * held collection no page holds, or whose holding is a problem; the message is held.ts's sentence);
641
687
  * - 404 `not-found` (delete: no such document);
642
- * - 409 `exists` (create: the document is already there), `address-taken` (another
688
+ * - 409 `changed` (arrange: the listing isn't the one the review was planned on), `exists` (create: the document is already there), `address-taken` (another
643
689
  * document has that address: "There's already a page at /services/packaging"),
644
690
  * `in-use` (a shared block something still uses; `usedOn` says where), `locked`
645
691
  * (live: someone else holds the document's lock: "Aoife is editing Contact"; the
@@ -667,7 +713,11 @@ export type CreateDeleteCode =
667
713
  | 'in-use'
668
714
  | 'holds'
669
715
  | 'locked'
670
- | 'same';
716
+ | 'same'
717
+ // Arrange pages (Decision 10): the listing changed since the review (ArrangeRequest.expect).
718
+ | 'changed'
719
+ // Arrange pages: an order-only Apply when Site Settings, which holds the order, isn't valid JSON.
720
+ | 'invalid-json';
671
721
 
672
722
  // ── Media (M5b Phase 2: Decisions 6, 7 and 12) ────────────────────────────────
673
723
 
package/src/index.ts CHANGED
@@ -125,6 +125,8 @@ export const EDITOR_ROUTES = [
125
125
  { pattern: '/_caracms/delete', entrypoint: 'editor/routes/delete.ts' },
126
126
  // Pre-M7 item 8: changing a document's address, both modes.
127
127
  { pattern: '/_caracms/move', entrypoint: 'editor/routes/move.ts' },
128
+ // Arrange pages: the arrange screen's Apply, both modes.
129
+ { pattern: '/_caracms/arrange', entrypoint: 'editor/routes/arrange.ts' },
128
130
  // M5b Phase 2: the media library, uploads and Mux, both modes (types.ts → MEDIA_ENDPOINTS).
129
131
  { pattern: '/_caracms/media/[...path]', entrypoint: 'editor/routes/media.ts' },
130
132
  // M4 Decision 14: GitHub's push webhook (signature-checked; 404 with no secret).
@@ -34,7 +34,8 @@
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';
37
+ import { assertSettingsReadable, orderEdit, planArrange } from '../editor/server/arrange.ts';
38
+ import { planCreate, planDelete, refused, target } from '../editor/server/create.ts';
38
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';
@@ -286,6 +287,9 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
286
287
  * can't commit a redirect for a move that then fails.
287
288
  * - **A document that was never live gets no redirect**: one made since the last Publish,
288
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.
289
293
  * Each refiled document's move is recorded for Publish ("Moved from"), and every changed
290
294
  * address's live one (autosaves.ts → `recordAddresses`), so a held document whose file
291
295
  * stays is named too.
@@ -299,7 +303,9 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
299
303
  const addresses = await autosaves.addresses();
300
304
  const onMain = (file: string) => git.changeOf(file, '') === 'changed';
301
305
  const wasLive = (p: MovePlan) => onMain(p.from.file) && !addresses.has(p.from.file);
302
- const changes = await movesChanges(site, plans, documents, (file) => source.readText(join(site.root, file)), (p) => request.redirect !== false && wasLive(p), request.editSettings);
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);
303
309
  const refiled = plans.filter((p) => p.refiled);
304
310
  const vacated = new Set(refiled.map((p) => p.from.file));
305
311
  const targets = new Set(refiled.map((p) => p.to.file));
@@ -531,12 +537,64 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
531
537
  }),
532
538
 
533
539
  move: async (request, context): Promise<MoveResponse> => {
534
- const moves = movesUnder(site, request, await listed());
535
- return moveMany({ moves, ...(request.redirect === false ? { redirect: false } : {}) }, context);
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);
536
544
  },
537
545
 
538
546
  moveMany: (request, context) => moveMany(request, context),
539
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) =>
557
+ guard(async () => {
558
+ const { person, tab } = who(context);
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
+ }
570
+ const took: string[] = [];
571
+ const written: Written[] = [];
572
+ try {
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() });
588
+ }
589
+ } catch (error) {
590
+ await undo(written, took, tab, person);
591
+ throw error;
592
+ }
593
+ for (const f of took) await locks.release(f, tab, person);
594
+ sweep.poke();
595
+ return { ok: true as const };
596
+ }),
597
+
540
598
  /**
541
599
  * M6c G4 (Decision 9): Replace… on a file. Every document with a link to the old file
542
600
  * (replace.ts, read with the autosaves on top) is this tab's before anything is written,
@@ -23,6 +23,8 @@
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';
27
29
  import { filesSource, heldBasesOf } from '../content.ts';
28
30
  import { brandFrom, editorBrand } from '../editor/brand.ts';
@@ -378,6 +380,8 @@ export async function createLiveEditor(
378
380
  : null,
379
381
  // M5a Decision 10: from the committed copy, never an autosave.
380
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))),
381
385
  };
382
386
  return json(200, body);
383
387
  });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * The Pages tree's saved order (arrange pages, Decisions 6, 7 and 19). Browser-safe: no
3
+ * node or disk imports, so the editor's client uses the same rules as the server.
4
+ *
5
+ * - **`PageOrder`** is Site Settings' `pageOrder`: a parent's path (`""` for the top level)
6
+ * → its children's slugs, pages and groups alike, in order. Absent means "the rule".
7
+ * - **The order of one level** (`orderSiblings`, Decision 7): Home first, always; then the
8
+ * siblings the saved list names, in that order (an unknown slug is ignored); then the
9
+ * unlisted ones, those with pages or a held collection under them first, then A–Z by
10
+ * label (content.ts's collator).
11
+ * - **After moves** (`orderAfterMoves`, Decision 6): a moved page's slug leaves its old
12
+ * parent's list and goes at the end of its new parent's (when that level has a saved
13
+ * list; with none, the rule places it); every key at or under a moved path is re-keyed,
14
+ * at any depth; a group renamed (every page under it moving to the same place under a new
15
+ * folder) changes its key and its slug in place; a list left empty is dropped.
16
+ * - **The Apply's merge** (`arrangedOrder`): the re-keyed saved order, then the request's
17
+ * own lists replacing those keys.
18
+ * - **The stamp** (`pageOrderStamp`, Decision 18): a short hash of the order, so another
19
+ * tab sees an order-only change on its next poll.
20
+ */
21
+
22
+ /** Site Settings' `pageOrder`: parent path (`""` is the top level) → its children's slugs in order. */
23
+ export type PageOrder = Record<string, string[]>;
24
+
25
+ /** One path that changes: a page's (or a group's folder's) path in the Pages collection, before → after. */
26
+ export interface PathChange {
27
+ from: string;
28
+ to: string;
29
+ }
30
+
31
+ /** The collection the saved order is for: the nested section builder at `/` (Pages). */
32
+ export function isOrderedCollection(collection: { nested?: boolean; urlBase: string | null }): boolean {
33
+ return !!collection.nested && collection.urlBase === '/';
34
+ }
35
+
36
+ /** A path's parent (`''` at the top level). */
37
+ export const parentOf = (path: string): string => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
38
+ /** A path's last part. */
39
+ export const slugOf = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
40
+ const isHome = (path: string) => path === 'index' || path.endsWith('/index');
41
+ const under = (path: string, folder: string) => path === folder || path.startsWith(`${folder}/`);
42
+
43
+ const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
44
+ /** A–Z by label, then by path (content.ts's order). */
45
+ export function byLabel(a: { label: string; path: string }, b: { label: string; path: string }): number {
46
+ return collator.compare(a.label, b.label) || collator.compare(a.path, b.path);
47
+ }
48
+
49
+ /**
50
+ * One level of the tree in order (Decision 7): Home first; then the siblings `saved` names,
51
+ * in its order; then the rest, those `filled` (pages or a held collection under them) first,
52
+ * then A–Z by label. `saved` undefined: the rule alone.
53
+ */
54
+ export function orderSiblings<T extends { path: string; label: string }>(items: readonly T[], saved: readonly string[] | undefined, filled: (item: T) => boolean): T[] {
55
+ const rank = new Map<string, number>();
56
+ (saved ?? []).forEach((slug, i) => {
57
+ if (!rank.has(slug)) rank.set(slug, i);
58
+ });
59
+ return [...items].sort((a, b) => {
60
+ const ha = isHome(a.path);
61
+ const hb = isHome(b.path);
62
+ if (ha !== hb) return ha ? -1 : 1;
63
+ const ra = rank.get(slugOf(a.path));
64
+ const rb = rank.get(slugOf(b.path));
65
+ if (ra !== undefined || rb !== undefined) {
66
+ if (ra === undefined) return 1;
67
+ if (rb === undefined) return -1;
68
+ if (ra !== rb) return ra - rb;
69
+ }
70
+ const fa = filled(a);
71
+ const fb = filled(b);
72
+ if (fa !== fb) return fa ? -1 : 1;
73
+ return byLabel(a, b);
74
+ });
75
+ }
76
+
77
+ /** `pageOrder` as read from a file: an object of string lists; anything else dropped, each list without repeats, empty lists left out. */
78
+ export function parsePageOrder(raw: unknown): PageOrder {
79
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
80
+ const out: PageOrder = {};
81
+ for (const [key, list] of Object.entries(raw as Record<string, unknown>)) {
82
+ if (!Array.isArray(list)) continue;
83
+ const slugs = [...new Set(list.filter((s): s is string => typeof s === 'string' && s !== ''))];
84
+ if (slugs.length) out[key] = slugs;
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** The order without its empty lists (and without repeats in a list). */
90
+ function tidy(order: PageOrder): PageOrder {
91
+ const out: PageOrder = {};
92
+ for (const [key, list] of Object.entries(order)) {
93
+ const slugs = [...new Set(list)];
94
+ if (slugs.length) out[key] = slugs;
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * The groups the moves rename: a folder with no page of its own (in `pathsBefore`, the Pages
101
+ * collection's paths before the moves) all of whose pages move to the same place under one
102
+ * new folder that's free once the moves land (a swap is fine). Topmost only.
103
+ */
104
+ export function groupRenames(pathsBefore: readonly string[], moves: readonly PathChange[]): PathChange[] {
105
+ const pages = new Set(pathsBefore);
106
+ const moving = new Map(moves.map((m) => [m.from, m.to]));
107
+ const folders = new Set<string>();
108
+ for (const p of pathsBefore) {
109
+ const parts = p.split('/');
110
+ for (let i = 1; i < parts.length; i++) folders.add(parts.slice(0, i).join('/'));
111
+ }
112
+ const renames: PathChange[] = [];
113
+ for (const group of [...folders].filter((f) => !pages.has(f)).sort((a, b) => a.split('/').length - b.split('/').length)) {
114
+ if (renames.some((r) => under(group, r.from))) continue;
115
+ const inside = pathsBefore.filter((p) => p.startsWith(`${group}/`));
116
+ let to: string | null = null;
117
+ const same = inside.every((p) => {
118
+ const target = moving.get(p);
119
+ const rest = p.slice(group.length);
120
+ if (target === undefined || !target.endsWith(rest)) return false;
121
+ const folder = target.slice(0, target.length - rest.length);
122
+ if (to === null) to = folder;
123
+ return folder === to;
124
+ });
125
+ if (!same || to === null || to === '' || to === group || under(to, group)) continue;
126
+ const into = to as string;
127
+ // The new folder is free once the moves land: whatever's there now moves away.
128
+ if (pathsBefore.some((p) => under(p, into) && !moving.has(p))) continue;
129
+ renames.push({ from: group, to: into });
130
+ }
131
+ return renames;
132
+ }
133
+
134
+ /**
135
+ * The saved order once these moves land (Decision 6). `moves`: every page path that changes
136
+ * (Change address…'s or an Apply's, expanded: the pages under a moved page included);
137
+ * `pathsBefore`: the Pages collection's paths before them, for the groups they rename.
138
+ */
139
+ export function orderAfterMoves(order: PageOrder, moves: readonly PathChange[], pathsBefore: readonly string[] = []): PageOrder {
140
+ const all = [...moves.filter((m) => m.from !== m.to), ...groupRenames(pathsBefore, moves)].sort((a, b) => a.from.split('/').length - b.from.split('/').length);
141
+ // The moves that aren't carried by a move above them: those change a list.
142
+ const units: PathChange[] = [];
143
+ for (const m of all) {
144
+ if (units.some((u) => u.from === m.from)) continue;
145
+ const carried = units.some((u) => m.from.startsWith(`${u.from}/`) && m.to === u.to + m.from.slice(u.from.length));
146
+ if (!carried) units.push(m);
147
+ }
148
+ if (units.length === 0) return tidy(order);
149
+ const rekey = (path: string) => {
150
+ let best: PathChange | null = null;
151
+ for (const u of units) if (under(path, u.from) && (!best || u.from.length > best.from.length)) best = u;
152
+ return best ? best.to + path.slice(best.from.length) : path;
153
+ };
154
+ const out: PageOrder = {};
155
+ for (const [key, list] of Object.entries(order)) {
156
+ const into = (out[rekey(key)] ??= []);
157
+ for (const slug of list) if (!into.includes(slug)) into.push(slug);
158
+ }
159
+ // Each unit's place in its old list, marked first, so a swap's slugs can't meet.
160
+ const marks = units.map((_, i) => `\u0000${i}`);
161
+ units.forEach((u, i) => {
162
+ const list = out[rekey(parentOf(u.from))];
163
+ const at = list ? list.indexOf(slugOf(u.from)) : -1;
164
+ if (at >= 0) list![at] = marks[i]!;
165
+ });
166
+ units.forEach((u, i) => {
167
+ const oldKey = rekey(parentOf(u.from));
168
+ const newKey = parentOf(u.to);
169
+ const slug = slugOf(u.to);
170
+ const old = out[oldKey];
171
+ const at = old ? old.indexOf(marks[i]!) : -1;
172
+ if (oldKey === newKey) {
173
+ // Renamed in place (a group rename, or a page's own slug): it keeps its place.
174
+ if (at >= 0) {
175
+ if (old!.includes(slug)) old!.splice(at, 1);
176
+ else old![at] = slug;
177
+ }
178
+ return;
179
+ }
180
+ if (at >= 0) old!.splice(at, 1);
181
+ const into = out[newKey];
182
+ if (into && !into.includes(slug)) into.push(slug);
183
+ });
184
+ for (const list of Object.values(out)) for (let i = list.length - 1; i >= 0; i--) if (list[i]!.startsWith('\u0000')) list.splice(i, 1);
185
+ return tidy(out);
186
+ }
187
+
188
+ /** The Apply's order (Contracts): the saved order re-keyed after the moves, then the request's lists replacing those keys, empty lists dropped. */
189
+ export function arrangedOrder(saved: PageOrder, requested: PageOrder, moves: readonly PathChange[], pathsBefore: readonly string[] = []): PageOrder {
190
+ return tidy({ ...orderAfterMoves(saved, moves, pathsBefore), ...requested });
191
+ }
192
+
193
+ /** The saved order's entries that name nothing: a key that's no page or group, or a slug that's no child of its key. `paths`: the Pages collection's paths. */
194
+ export function unknownOrderEntries(order: PageOrder, paths: readonly string[]): { key: string; slug: string | null }[] {
195
+ const known = new Set<string>(['']);
196
+ for (const p of paths) {
197
+ const parts = p.split('/');
198
+ for (let i = 1; i <= parts.length; i++) known.add(parts.slice(0, i).join('/'));
199
+ }
200
+ const out: { key: string; slug: string | null }[] = [];
201
+ for (const [key, list] of Object.entries(order)) {
202
+ if (!known.has(key)) {
203
+ out.push({ key, slug: null });
204
+ continue;
205
+ }
206
+ for (const slug of list) if (!known.has(key === '' ? slug : `${key}/${slug}`)) out.push({ key, slug });
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /** The order without the entries `unknownOrderEntries` finds, and how many slugs went. */
212
+ export function prunedOrder(order: PageOrder, paths: readonly string[]): { order: PageOrder; pruned: number } {
213
+ const unknown = unknownOrderEntries(order, paths);
214
+ if (unknown.length === 0) return { order, pruned: 0 };
215
+ const out: PageOrder = {};
216
+ let pruned = 0;
217
+ for (const [key, list] of Object.entries(order)) {
218
+ if (unknown.some((u) => u.key === key && u.slug === null)) {
219
+ pruned += list.length;
220
+ continue;
221
+ }
222
+ const kept = list.filter((slug) => !unknown.some((u) => u.key === key && u.slug === slug));
223
+ pruned += list.length - kept.length;
224
+ if (kept.length) out[key] = kept;
225
+ }
226
+ return { order: out, pruned };
227
+ }
228
+
229
+ /** FNV-1a over a text, in base 36. */
230
+ function fnv(text: string): string {
231
+ let hash = 0x811c9dc5;
232
+ for (let i = 0; i < text.length; i++) {
233
+ hash ^= text.charCodeAt(i);
234
+ hash = Math.imul(hash, 0x01000193) >>> 0;
235
+ }
236
+ return hash.toString(36);
237
+ }
238
+
239
+ /**
240
+ * Decision 10: a short hash of a collection's document paths, sorted (FNV-1a), so the Apply
241
+ * can tell the listing it was planned on from the one it lands on (`ArrangeRequest.expect`).
242
+ */
243
+ export function listingStamp(paths: readonly string[]): string {
244
+ return fnv(JSON.stringify([...paths].sort()));
245
+ }
246
+
247
+ /** Decision 18: a short hash of the order (FNV-1a over its keys in order); `""` when there's none. */
248
+ export function pageOrderStamp(order: PageOrder | null | undefined): string {
249
+ if (!order) return '';
250
+ const keys = Object.keys(order).sort();
251
+ if (keys.length === 0) return '';
252
+ return fnv(JSON.stringify(keys.map((k) => [k, order[k]])));
253
+ }
package/src/redirects.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * parseRedirects(raw) the list as the file holds it, cleaned
8
8
  * redirectsAfterMove(list, from, to, add) the list once a document moves
9
- * redirectsAfterMoves(list, moves) the list once several move at once (swaps, chains)
9
+ * redirectsAfterMoves(list, moves, homes?) the list once several move at once (swaps, chains)
10
10
  * redirectFor(list, pathname) where a request for `pathname` goes, or null
11
11
  *
12
12
  * - **Both ends are `/paths`**, without a trailing slash (`/` stays `/`). An entry that
@@ -65,6 +65,29 @@ export function redirectsAfterMove(list: readonly Redirect[], from: string, to:
65
65
  return next;
66
66
  }
67
67
 
68
+ /**
69
+ * A document's live address (the one the public site served at the last Publish) and
70
+ * where it is once the plan has landed: live mode's documents moved since the last Publish
71
+ * (autosaves.ts → `recordAddresses`). `redirectsAfterMoves` adds `live → now` when a move
72
+ * in the plan vacates `live`.
73
+ */
74
+ export interface LiveHome {
75
+ live: string;
76
+ now: string;
77
+ }
78
+
79
+ /** The homes whose live address a move in the plan vacates and no document then takes: each adds `live → now`. */
80
+ function strandedHomes(moves: readonly { from: string; to: string }[], homes: readonly LiveHome[]): Redirect[] {
81
+ const taken = (address: string) => moves.some((m) => same(m.to, address));
82
+ const out: Redirect[] = [];
83
+ for (const h of homes) {
84
+ if (same(h.live, h.now) || taken(h.live) || !moves.some((m) => same(m.from, h.live))) continue;
85
+ if (out.some((r) => same(r.from, h.live))) continue;
86
+ out.push({ from: h.live, to: h.now });
87
+ }
88
+ return out;
89
+ }
90
+
68
91
  /**
69
92
  * The list once several documents move at once (the many-move engine): every move is
70
93
  * taken against the list before the plan, so a swap or a chain comes out as its
@@ -74,10 +97,17 @@ export function redirectsAfterMove(list: readonly Redirect[], from: string, to:
74
97
  * `from → to` is added, unless another document now sits at `from` (in a swap neither
75
98
  * is added). With no address both vacated and taken, this is `redirectsAfterMove` per
76
99
  * move, in order.
100
+ *
101
+ * `homes` (live mode, and only when the move leaves redirects): when a move vacates an
102
+ * address that is another document's live address (a chain before a Publish left that
103
+ * document without a redirect from it, since a document sat there), `live → now` is added
104
+ * too, replacing any entry from that address, so the public site's old address still
105
+ * finds its document after the Publish.
77
106
  */
78
- export function redirectsAfterMoves(list: readonly Redirect[], moves: readonly { from: string; to: string; add: boolean }[]): Redirect[] {
107
+ export function redirectsAfterMoves(list: readonly Redirect[], moves: readonly { from: string; to: string; add: boolean }[], homes: readonly LiveHome[] = []): Redirect[] {
79
108
  const taken = (address: string) => moves.some((m) => same(m.to, address));
80
- const replaced = (address: string) => moves.some((m) => m.add && same(m.from, address));
109
+ const stranded = strandedHomes(moves, homes);
110
+ const replaced = (address: string) => moves.some((m) => m.add && same(m.from, address)) || stranded.some((r) => same(r.from, address));
81
111
  const next = list
82
112
  .filter((r) => !taken(r.from) && !replaced(r.from))
83
113
  .map((r) => {
@@ -85,14 +115,16 @@ export function redirectsAfterMoves(list: readonly Redirect[], moves: readonly {
85
115
  return m ? { from: r.from, to: m.to } : r;
86
116
  })
87
117
  .filter((r) => !same(r.from, r.to));
88
- for (const m of moves) if (m.add && !same(m.from, m.to) && !taken(m.from)) next.push({ from: m.from, to: m.to });
118
+ for (const r of redirectsAddedBy(moves, homes)) next.push(r);
89
119
  return next;
90
120
  }
91
121
 
92
- /** The redirects a plan adds (`redirectsAfterMoves`): one per move with `add`, unless another document now sits at its old address. */
93
- export function redirectsAddedBy(moves: readonly { from: string; to: string; add: boolean }[]): Redirect[] {
122
+ /** The redirects a plan adds (`redirectsAfterMoves`): one per move with `add`, unless another document now sits at its old address, and one per stranded live address (`homes`). */
123
+ export function redirectsAddedBy(moves: readonly { from: string; to: string; add: boolean }[], homes: readonly LiveHome[] = []): Redirect[] {
94
124
  const taken = (address: string) => moves.some((m) => same(m.to, address));
95
- return moves.filter((m) => m.add && !same(m.from, m.to) && !taken(m.from)).map((m) => ({ from: m.from, to: m.to }));
125
+ const added = moves.filter((m) => m.add && !same(m.from, m.to) && !taken(m.from)).map((m) => ({ from: m.from, to: m.to }));
126
+ for (const r of strandedHomes(moves, homes)) if (!added.some((a) => same(a.from, r.from))) added.push(r);
127
+ return added;
96
128
  }
97
129
 
98
130
  /** Where a request for `pathname` goes, or null. Matched without a trailing slash; letter case counts, as it does for pages. */
package/src/reserved.ts CHANGED
@@ -19,6 +19,7 @@ export const RESERVED_COLLECTIONS = ['shared', 'settings'] as const;
19
19
  * The keys of Site Settings the package owns (Decision 10); the site's own
20
20
  * `settings.fields` can't use them. M6 adds the search and sharing defaults, the
21
21
  * Google tag and the form emails' recipients (M6 Decision 14); pre-M7 item 8, the
22
- * redirects (redirects.ts).
22
+ * redirects (redirects.ts); arrange pages (Decision 6), the Pages tree's saved order
23
+ * (page-order.ts), which only the arrange screen writes.
23
24
  */
24
- export const SETTINGS_KEYS = ['title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects'] as const;
25
+ export const SETTINGS_KEYS = ['title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects', 'pageOrder'] as const;
package/src/serialize.ts CHANGED
@@ -57,6 +57,7 @@ import {
57
57
  import { fieldToJson, isEmptyValue, parseField, type Field, type Fields } from './fields.ts';
58
58
  import { mediaLine } from './media-value.ts';
59
59
  import { parseRedirects } from './redirects.ts';
60
+ import { parsePageOrder } from './page-order.ts';
60
61
  import { member, parseSource, slice, type ArrayNode, type JsonNode, type MemberNode, type ObjectNode, type ParsedSource } from './json-source.ts';
61
62
  import { isEmptySeo, parseFormRecipients, parseGoogleTag, parseSeo, seoText, type Seo } from './seo.ts';
62
63
  import { SCHEMA_KEY } from './schema-key.ts';
@@ -384,6 +385,18 @@ const redirectsCodec: KeyCodec = {
384
385
  removeWhenEmpty: true,
385
386
  };
386
387
 
388
+ /** Arrange pages (Decision 6): the saved order, one parent's list per line. */
389
+ const pageOrderCodec: KeyCodec = {
390
+ parse: parsePageOrder,
391
+ text: (v, depth) => {
392
+ const entries = Object.entries(parsePageOrder(v));
393
+ if (entries.length === 0) return '{}';
394
+ return `{\n${entries.map(([k, list]) => `${pad(depth + 1)}${JSON.stringify(k)}: [${list.map((s) => JSON.stringify(s)).join(', ')}]`).join(',\n')}\n${pad(depth)}}`;
395
+ },
396
+ empty: (v) => Object.keys(parsePageOrder(v)).length === 0,
397
+ removeWhenEmpty: true,
398
+ };
399
+
387
400
  const fieldCodec = (field: Field): KeyCodec => ({
388
401
  parse: (raw) => parseField(field, raw),
389
402
  text: (value, depth) => fieldText(field, value, depth),
@@ -419,6 +432,7 @@ function codecsFor(collection: Pick<ResolvedCollection, 'kind' | 'fields'>): Map
419
432
  codecs.set('googleTag', googleTagCodec);
420
433
  codecs.set('formRecipients', recipientsCodec);
421
434
  codecs.set('redirects', redirectsCodec);
435
+ codecs.set('pageOrder', pageOrderCodec);
422
436
  }
423
437
  for (const [key, field] of Object.entries(collection.fields ?? ({} as Fields))) codecs.set(key, fieldCodec(field));
424
438
  return codecs;
@@ -692,6 +706,9 @@ export function savedForm(text: string, collection: Pick<ResolvedCollection, 'ki
692
706
  // brandCodec: logo and icon on one line each; redirectsCodec: one redirect per line.
693
707
  keys.set('brand', { kind: 'object', keys: new Map([['logo', INLINE], ['icon', INLINE]]) });
694
708
  keys.set('redirects', { kind: 'list', item: INLINE });
709
+ // pageOrderCodec: one parent's list per line.
710
+ const order = (raw as Record<string, unknown>).pageOrder;
711
+ if (order && typeof order === 'object' && !Array.isArray(order)) keys.set('pageOrder', { kind: 'object', keys: new Map(Object.keys(order).map((k) => [k, INLINE])) });
695
712
  }
696
713
  return `${layout(raw, { kind: 'object', keys }, 0)}\n`;
697
714
  }