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
package/src/content.ts CHANGED
@@ -11,9 +11,11 @@
11
11
  * is the URL, `index.json` at the top is the collection's `urlBase`, and an
12
12
  * `index.json` any deeper is an error, listed with a warning and not opened.
13
13
  * - **Order** (Decision 8): the reader provides it. `listDocuments` returns a nested
14
- * collection in tree order (each level: Home first, then alphabetical by title,
15
- * with a folder that has no page of its own sorted by its label), and a flat
16
- * collection alphabetically by title. `pageTree` builds the tree itself.
14
+ * collection in tree order, and a flat collection alphabetically by title. `pageTree`
15
+ * builds the tree itself; each level follows page-order.ts (arrange pages, Decision 7:
16
+ * Home first, then Site Settings' saved `pageOrder` for Pages, read from the same
17
+ * source first, then what has pages under it, then A–Z, a folder with no page of its
18
+ * own sorted by its label).
17
19
  * - A file that isn't valid JSON, or has no `title`, is listed with a warning and
18
20
  * can't be opened (Decision 9).
19
21
  * - **Two sources, one set of rules** (M4 Current state): the files come from a
@@ -36,6 +38,11 @@ import { isBuiltInColumn } from './columns.ts';
36
38
  import { fieldsCover, sectionsCover, type CardCover } from './cards.ts';
37
39
  import { own, parseAnyDocument, type AnyDocument, type DocumentKind } from './document.ts';
38
40
  import { parseSource } from './json-source.ts';
41
+ import { hasHeld, heldBases, type HeldBase } from './held.ts';
42
+ import { leftBehindMessage, leftBehindRoutes, ownRoutedCollections } from './code-routes.ts';
43
+ import { sameAddress } from './slug.ts';
44
+ import { byLabel, isOrderedCollection, orderSiblings, parsePageOrder, type PageOrder } from './page-order.ts';
45
+ import { SITE_SETTINGS_FILE } from './reserved.ts';
39
46
 
40
47
  // Posts and tags, Decision 14: a route's reader on the disk (read.ts).
41
48
  export { contentReader } from './read.ts';
@@ -79,6 +86,21 @@ export interface DocumentEntry {
79
86
  */
80
87
  hidden?: true;
81
88
  hiddenBy?: string;
89
+ /**
90
+ * The page's own `"hidden": true`, kept apart from `hiddenBy`: a page hidden itself and also
91
+ * under a hidden page has both, and the arrange screen needs to know it stays hidden when moved.
92
+ */
93
+ hiddenSelf?: true;
94
+ /**
95
+ * Collections under pages (Decision 11): set when `hiddenBy` is a page of another collection,
96
+ * the page that holds this document's collection (`pages`). Absent: `hiddenBy` is in this one.
97
+ */
98
+ hiddenByCollection?: string;
99
+ /**
100
+ * Collections under pages (Decision 5): a page's `"holds"`, the collection it holds (held.ts).
101
+ * Read from a file that can't be opened too, as far as it can be, so a broken holder is named.
102
+ */
103
+ holds?: string;
82
104
  /** A section builder's shared-block entries, by shared block path, in block order, once each (Decision 8). Absent otherwise. */
83
105
  uses?: string[];
84
106
  /** A shared block: its block's type (`''` when it has none). Absent otherwise. */
@@ -220,13 +242,17 @@ export function resolveDocumentFile(site: Pick<ResolvedSite, 'collections'>, col
220
242
  return file;
221
243
  }
222
244
 
223
- /** Every document in one collection, or in all of them (in config order), with warnings, in the editor's order. */
245
+ /**
246
+ * Every document in one collection, in several (in the order given), or in all of them (in
247
+ * config order), with warnings, in the editor's order. A held collection's documents have
248
+ * their resolved addresses (held.ts), whichever collections are listed.
249
+ */
224
250
  export async function listDocuments(
225
251
  site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>,
226
- collectionName?: string,
252
+ collectionName?: string | readonly string[],
227
253
  source: ContentSource = diskSource,
228
254
  ): Promise<DocumentEntry[]> {
229
- const names = collectionName ? [collectionName] : Object.keys(site.collections);
255
+ const names = collectionName === undefined ? Object.keys(site.collections) : typeof collectionName === 'string' ? [collectionName] : [...collectionName];
230
256
  const all: DocumentEntry[] = [];
231
257
  for (const name of names) {
232
258
  const collection = own(site.collections, name);
@@ -234,9 +260,24 @@ export async function listDocuments(
234
260
  const paths = await documentPaths(collection, source);
235
261
  const entries = (await Promise.all(paths.map((path) => entryFor(site, collection, path, source)))).filter((e) => e !== null);
236
262
  if (collection.nested) hideChildren(entries);
237
- all.push(...(collection.nested ? flattenTree(pageTree(entries)) : entries.sort(byLabel)));
263
+ // Arrange pages (Decision 6): Pages follow Site Settings' saved order, read through the same source.
264
+ const order = isOrderedCollection(collection) ? await savedPageOrder(site, source) : undefined;
265
+ all.push(...(collection.nested ? flattenTree(pageTree(entries, order)) : entries.sort(byLabel)));
266
+ }
267
+ // Collections under pages (Decision 7): a held collection's addresses come from its holder's listing.
268
+ const held = names.map((n) => site.collections[n]!).filter((c) => c.heldBy);
269
+ if (held.length) {
270
+ const holding: DocumentEntry[] = [];
271
+ for (const by of new Set(held.map((c) => c.heldBy!))) {
272
+ holding.push(...(names.includes(by) ? all.filter((e) => e.collection === by) : await listDocuments(site, by, source)));
273
+ }
274
+ const bases = heldBases(site.collections, holding);
275
+ for (const entry of all) {
276
+ const base = own(bases, entry.collection);
277
+ if (base) placeHeld(entry, base);
278
+ }
238
279
  }
239
- if (!collectionName) {
280
+ if (collectionName === undefined) {
240
281
  // Decision 8's "used on": every document whose block list has an entry for the shared block.
241
282
  for (const entry of all) {
242
283
  if (entry.kind !== 'shared') continue;
@@ -246,6 +287,59 @@ export async function listDocuments(
246
287
  return all;
247
288
  }
248
289
 
290
+ /**
291
+ * Arrange pages (Decision 6): Site Settings' `pageOrder` through `source` (live mode: the
292
+ * `drafts` copy with the autosaves on top); `{}` when there's none or the file can't be read.
293
+ */
294
+ export async function savedPageOrder(site: Pick<ResolvedSite, 'root'>, source: ContentSource = diskSource): Promise<PageOrder> {
295
+ const text = await source.readText(join(site.root, SITE_SETTINGS_FILE)).catch(() => null);
296
+ if (text === null) return {};
297
+ try {
298
+ const raw = JSON.parse(text) as unknown;
299
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? parsePageOrder((raw as Record<string, unknown>).pageOrder) : {};
300
+ } catch {
301
+ return {};
302
+ }
303
+ }
304
+
305
+ /** A held document's address and hidden mark from its collection's resolved base (held.ts). */
306
+ function placeHeld(entry: DocumentEntry, held: HeldBase): void {
307
+ entry.url = held.base === null ? null : held.base + entry.path;
308
+ // Decision 11: a hidden holder hides its documents, as a hidden page hides the pages under it.
309
+ if (held.hidden && held.holder) {
310
+ entry.hidden = true;
311
+ entry.hiddenBy = held.holder.path;
312
+ entry.hiddenByCollection = held.holder.collection;
313
+ }
314
+ }
315
+
316
+ /** A held collection's resolved base, from its holder collection's listing through `source`. */
317
+ export async function heldBaseOf(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, collectionName: string, source: ContentSource = diskSource): Promise<HeldBase | null> {
318
+ const collection = own(site.collections, collectionName);
319
+ if (!collection?.heldBy) return null;
320
+ return heldBases({ [collection.name]: collection }, await listDocuments(site, collection.heldBy, source))[collection.name] ?? null;
321
+ }
322
+
323
+ /** Every held collection's resolved base (held.ts), from the listings of the collections that hold them; `{}` on a site with none. */
324
+ export async function heldBasesOf(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, source: ContentSource = diskSource): Promise<Record<string, HeldBase>> {
325
+ if (!hasHeld(site.collections)) return {};
326
+ const by = [...new Set(Object.values(site.collections).flatMap((c) => (c.heldBy ? [c.heldBy] : [])))];
327
+ const holding = (await Promise.all(by.map((name) => listDocuments(site, name, source)))).flat();
328
+ return heldBases(site.collections, holding);
329
+ }
330
+
331
+ /**
332
+ * A document's public address as its listing gives it: `documentUrl` for a collection with a
333
+ * prefix, the holder's address + `/` + the path for a held one (null while it isn't held).
334
+ */
335
+ export async function documentAddress(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, collectionName: string, path: string, source: ContentSource = diskSource): Promise<string | null> {
336
+ const collection = own(site.collections, collectionName);
337
+ if (!collection) return null;
338
+ if (!collection.heldBy) return documentUrl(collection, path);
339
+ const held = await heldBaseOf(site, collectionName, source);
340
+ return held?.base ? held.base + path : null;
341
+ }
342
+
249
343
  /** M9 Decision 7: a hidden page's children are hidden with it; `hiddenBy` names the topmost hidden page above. */
250
344
  function hideChildren(entries: DocumentEntry[]): void {
251
345
  const hidden = new Set(entries.filter((e) => e.hidden).map((e) => e.path));
@@ -279,6 +373,10 @@ export async function readDocument(
279
373
  if (text === null) throw new DocumentError('not-found', `${collection.label}: no document "${path}".`);
280
374
  const { entry, document } = inspect(site, collection, path, text);
281
375
  if (entry.warning || !document) throw new DocumentError(entry.warning!.code, entry.warning!.message);
376
+ if (collection.heldBy) {
377
+ const held = await heldBaseOf(site, collection.name, source);
378
+ if (held) placeHeld(entry, held);
379
+ }
282
380
  return { entry, text, document };
283
381
  }
284
382
 
@@ -312,10 +410,12 @@ function inspect(
312
410
  url: documentUrl(collection, path),
313
411
  };
314
412
  const fileLabel = titleCase(path.split('/').pop()!);
315
- const warn = (code: WarningCode, message: string, title = '') => ({
316
- entry: { ...base, title, label: title || fileLabel, warning: { code, message } },
317
- document: null,
318
- });
413
+ // Decision 13: a broken holder is named, so its `"holds"` is read from the text as far as it can be.
414
+ const holdsIn = () => (collection.kind === 'sections' ? /"holds"\s*:\s*"([^"\\]*)"/.exec(text)?.[1] : undefined);
415
+ const warn = (code: WarningCode, message: string, title = '') => {
416
+ const holds = holdsIn();
417
+ return { entry: { ...base, title, label: title || fileLabel, warning: { code, message }, ...(holds !== undefined ? { holds } : {}) }, document: null };
418
+ };
319
419
 
320
420
  if (collection.nested && path.endsWith('/index')) {
321
421
  const parent = path.slice(0, -'/index'.length);
@@ -335,7 +435,8 @@ function inspect(
335
435
  }
336
436
  const entry: DocumentEntry = { ...base, title, label: title, warning: null };
337
437
  // M9 Decision 7: the home page is never hidden.
338
- if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = true;
438
+ if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = entry.hiddenSelf = true;
439
+ if ((document.kind === undefined || document.kind === 'sections') && document.holds !== undefined) entry.holds = document.holds;
339
440
  if (document.kind === undefined || document.kind === 'sections') {
340
441
  entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
341
442
  } else if (document.kind === 'shared') {
@@ -375,10 +476,11 @@ export type PageTreeNode =
375
476
  /**
376
477
  * A nested collection's entries as a tree that mirrors their paths. A page with
377
478
  * pages under it (`about.json` beside `about/`) is one node with children; a folder
378
- * with no page is a group. Each level is ordered Home (`index`) first, then
379
- * alphabetically by label.
479
+ * with no page is a group. Each level is ordered by page-order.ts → `orderSiblings`
480
+ * (arrange pages, Decision 7): Home (`index`) first, then the siblings `order` lists
481
+ * for that level, then those with pages or a held collection under them, then A–Z.
380
482
  */
381
- export function pageTree(entries: readonly DocumentEntry[]): PageTreeNode[] {
483
+ export function pageTree(entries: readonly DocumentEntry[], order: PageOrder = {}): PageTreeNode[] {
382
484
  interface Draft {
383
485
  path: string;
384
486
  entry?: DocumentEntry;
@@ -400,17 +502,15 @@ export function pageTree(entries: readonly DocumentEntry[]): PageTreeNode[] {
400
502
  node.entry = entry;
401
503
  }
402
504
  const build = (draft: Draft): PageTreeNode[] =>
403
- [...draft.children.values()]
404
- .map((d): PageTreeNode => {
505
+ orderSiblings(
506
+ [...draft.children.values()].map((d): PageTreeNode => {
405
507
  const children = build(d);
406
508
  if (d.entry) return { kind: 'page', path: d.path, label: d.entry.label, entry: d.entry, children };
407
509
  return { kind: 'group', path: d.path, label: titleCase(d.path.split('/').pop()!), children };
408
- })
409
- .sort((a, b) => {
410
- if (a.path === 'index' || a.path.endsWith('/index')) return -1;
411
- if (b.path === 'index' || b.path.endsWith('/index')) return 1;
412
- return byLabel(a, b);
413
- });
510
+ }),
511
+ Object.hasOwn(order, draft.path) ? order[draft.path] : undefined,
512
+ (n) => n.children.length > 0 || (n.kind === 'page' && n.entry.holds !== undefined),
513
+ );
414
514
  return build(top);
415
515
  }
416
516
 
@@ -418,7 +518,68 @@ function flattenTree(nodes: PageTreeNode[]): DocumentEntry[] {
418
518
  return nodes.flatMap((n) => [...(n.kind === 'page' ? [n.entry] : []), ...flattenTree(n.children)]);
419
519
  }
420
520
 
421
- const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
422
- function byLabel(a: { label: string; path: string }, b: { label: string; path: string }): number {
423
- return collator.compare(a.label, b.label) || collator.compare(a.path, b.path);
521
+
522
+ // ── The catch-all's reader (collections under pages, Decision 14) ────────────────
523
+
524
+ /** A public page the site's catch-all serves: its collection, its path there, and its address. */
525
+ export interface AddressedDocument {
526
+ collection: string;
527
+ path: string;
528
+ /** `/`, `/about`, `/journal/a-day-on-the-ferry`: no trailing slash, as the listing gives it. */
529
+ url: string;
530
+ }
531
+
532
+ /** What the reader reads: the site, and the routes in its `src/pages/` (the integration sets `pageRoutes`). */
533
+ type CatchAllSite = Pick<ResolvedSite, 'collections' | 'root' | 'blocks'> & Partial<Pick<ResolvedSite, 'pageRoutes'>>;
534
+
535
+ const logged = new Set<string>();
536
+ /** The build's and dev's log, once per sentence per process (Decision 13: logged, never a failed build). */
537
+ function logOnce(message: string): void {
538
+ if (logged.has(message)) return;
539
+ logged.add(message);
540
+ console.warn(`[caracms] ${message}`);
541
+ }
542
+
543
+ /**
544
+ * Every public page of every collection with an address, for the site's one catch-all route
545
+ * (`src/pages/[...path].astro`'s `getStaticPaths`): Pages, every held collection's documents at
546
+ * their holder's address, and every prefix collection's (`/tags/<slug>`), in config order then
547
+ * the editor's. Left out: a file that can't be opened, a hidden page (and the documents of a
548
+ * hidden holder), a held collection no page holds, and a prefix collection whose base has its
549
+ * own route (`src/pages/tags/[slug].astro`), which that route serves. A holder problem or a
550
+ * route left at a held base is logged once (`caracms validate` reports both as errors).
551
+ * `source`: where the files come from (the disk by default, as a build reads them).
552
+ */
553
+ export async function addressedDocuments(site: CatchAllSite, source: ContentSource = diskSource): Promise<AddressedDocument[]> {
554
+ const collections = Object.values(site.collections);
555
+ const routes = site.pageRoutes ?? [];
556
+ const ownRouted = new Set(ownRoutedCollections(routes, collections).map((c) => c.name));
557
+ const names = collections.filter((c) => (c.urlBase !== null || c.heldBy) && !ownRouted.has(c.name)).map((c) => c.name);
558
+ const entries = await listDocuments(site, names, source);
559
+ if (hasHeld(site.collections)) {
560
+ const held: { name: string; label: string; base: string; holder: string }[] = [];
561
+ for (const base of Object.values(await heldBasesOf(site, source))) {
562
+ if (base.problem) logOnce(base.problem);
563
+ const c = site.collections[base.collection]!;
564
+ if (base.base && base.holder) held.push({ name: c.name, label: c.label, base: base.base, holder: base.holder.label });
565
+ }
566
+ for (const left of leftBehindRoutes(routes, held)) logOnce(leftBehindMessage(left));
567
+ }
568
+ return entries.filter((e): e is DocumentEntry & { url: string } => e.url !== null && !e.warning && !e.hidden).map(({ collection, path, url }) => ({ collection, path, url }));
569
+ }
570
+
571
+ /**
572
+ * The document a request's path is (`/journal/a-day-on-the-ferry`, with or without a trailing
573
+ * slash, URL-encoded or not), for a catch-all that renders on demand; null when no public page
574
+ * is there (`addressedDocuments` says which are).
575
+ */
576
+ export async function documentAt(site: CatchAllSite, pathname: string, source: ContentSource = diskSource): Promise<{ collection: string; path: string } | null> {
577
+ let at: string;
578
+ try {
579
+ at = sameAddress(decodeURIComponent(pathname));
580
+ } catch {
581
+ return null;
582
+ }
583
+ const found = (await addressedDocuments(site, source)).find((d) => sameAddress(d.url) === at);
584
+ return found ? { collection: found.collection, path: found.path } : null;
424
585
  }
package/src/document.ts CHANGED
@@ -25,6 +25,7 @@ import { parseSource, type ParsedSource } from './json-source.ts';
25
25
  import { isFileKey } from './files.ts';
26
26
  import { parseMedia } from './media-value.ts';
27
27
  import { parseRedirects, type Redirect } from './redirects.ts';
28
+ import { parsePageOrder, type PageOrder } from './page-order.ts';
28
29
  import { parseFormRecipients, parseGoogleTag, parseSeo, type Seo } from './seo.ts';
29
30
 
30
31
  export interface DocumentBlock {
@@ -67,6 +68,13 @@ export interface SiteDocument {
67
68
  * the key out of the file. The home page (`index`) can't be hidden (content.ts reads it as shown).
68
69
  */
69
70
  hidden?: boolean;
71
+ /**
72
+ * Collections under pages (Decision 5): the collection this page holds (the file's
73
+ * top-level `"holds"`, after `title` and `hidden`): its documents are at this page's
74
+ * address + `/` + their slug (held.ts). Edited in the file only, never by the editor,
75
+ * whose saves keep it where it is.
76
+ */
77
+ holds?: string;
70
78
  /**
71
79
  * M6c (G2): the page fields' parsed values, keyed by field, every field of the
72
80
  * collection's `fields` present (the file's top-level keys after `title`). Present
@@ -121,14 +129,16 @@ export interface SettingsBrand {
121
129
  export interface SettingsDocument {
122
130
  kind: 'settings';
123
131
  title: string;
124
- data: Record<string, unknown> & { editors: string[]; brand: SettingsBrand; seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[] };
132
+ data: Record<string, unknown> & { editors: string[]; brand: SettingsBrand; seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[]; pageOrder?: PageOrder };
125
133
  }
126
134
 
127
135
  /** M6: Site Settings' search and sharing keys, parsed, each only when `raw` has it. */
128
- function settingsM6(raw: Record<string, unknown>): { seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[] } {
136
+ function settingsM6(raw: Record<string, unknown>): { seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[]; pageOrder?: PageOrder } {
129
137
  return {
130
138
  // Pre-M7 item 8: the redirects changing an address adds (redirects.ts).
131
139
  ...(Object.hasOwn(raw, 'redirects') ? { redirects: parseRedirects(raw.redirects) } : {}),
140
+ // Arrange pages (Decision 6): the Pages tree's saved order (page-order.ts).
141
+ ...(Object.hasOwn(raw, 'pageOrder') ? { pageOrder: parsePageOrder(raw.pageOrder) } : {}),
132
142
  ...(Object.hasOwn(raw, 'seo') ? { seo: parseSeo(raw.seo) } : {}),
133
143
  ...(Object.hasOwn(raw, 'googleTag') ? { googleTag: parseGoogleTag(raw.googleTag) } : {}),
134
144
  ...(Object.hasOwn(raw, 'formRecipients') ? { formRecipients: parseFormRecipients(raw.formRecipients) } : {}),
@@ -225,6 +235,7 @@ export function parseDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>):
225
235
  blocks: rawBlocks.map((b, index) => parseBlock(b, index, site)),
226
236
  ...seoOf(doc),
227
237
  ...(doc.hidden === true ? { hidden: true } : {}),
238
+ ...(typeof doc.holds === 'string' ? { holds: doc.holds } : {}),
228
239
  };
229
240
  }
230
241
 
@@ -131,6 +131,9 @@ client/ the browser (and the shell's server render)
131
131
  fields/ the block level's fields: FieldFrame, Text, Select, Lockup, lockup-rules.ts
132
132
  shell/ App, Bar, Stage, Popovers, Toast, NarrowNote, Icons
133
133
  pane/ Pane.tsx (the frame), parts.tsx (shared pieces), switcher.tsx (Phase 4's seam), levels/*
134
+ arrange/ Arrange pages (notes/2026-10-25-arrange-pages.md): model.ts (the tree, the plan, the checks, the
135
+ re-plan; pure), store.ts (arrangeFor(editor): the screen's state, Apply), ArrangeStage.tsx (the
136
+ screen in the preview's place, mouse drag); its pane level is pane/levels/ArrangeLevel.tsx
134
137
  styles/ tokens.css (the mockup's :root, as is), editor.css (the mockup's chrome CSS), fonts.css,
135
138
  levels/*.css (a level's own CSS, loaded by the admin route)
136
139
  fonts/ Instrument Sans and Geist Mono (OFL, licences beside them)
@@ -30,7 +30,12 @@ export function inboxFromAddress(pathname: string): { id: string | null } | 'bad
30
30
  return isSubmissionId(rest) ? { id: rest } : 'bad';
31
31
  }
32
32
 
33
- export const inboxAddress = (id: string | null): string => (id ? `${SUBMISSIONS_ADDRESS}/${encodeURIComponent(id)}` : SUBMISSIONS_ADDRESS);
33
+ /** Arrange pages' address (Decision 15): the screen, with nothing arranged on a reload. */
34
+ export const ARRANGE_ADDRESS = `${ADMIN}/arrange`;
35
+
36
+ export const isArrangeAddress = (pathname: string): boolean => pathname.replace(/\/+$/, '') === ARRANGE_ADDRESS;
37
+
38
+ export const inboxAddress =(id: string | null): string => (id ? `${SUBMISSIONS_ADDRESS}/${encodeURIComponent(id)}` : SUBMISSIONS_ADDRESS);
34
39
 
35
40
  export function addressOf(ref: DocRef, home: DocRef): string {
36
41
  if (sameDoc(ref, home)) return ADMIN;