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.
package/README.md CHANGED
@@ -80,6 +80,20 @@ renders on demand uses `documentAt(site, pathname)`. A collection needs no route
80
80
  own; a prefix collection whose base keeps its own route file is left out of the reader,
81
81
  and a route with parameters left at a held collection's address is a `validate` error.
82
82
 
83
+ ## Arranging pages
84
+
85
+ The editor's Pages tree reads Home first, then the pages and groups with pages (or a held
86
+ collection) under them, then the rest, each A–Z. To change that, "Arrange pages…" in the
87
+ tree's header opens a screen at `/admin/arrange`: drag pages to order them, nest one under
88
+ a page or group, make and rename groups, then review every old → new address and apply it
89
+ as one change, with one tick for redirects from the old addresses. Nothing changes until
90
+ Apply, and if the pages change underneath it, Apply refuses and shows the review again.
91
+
92
+ The order is saved in Site Settings as `pageOrder` (each parent's path → its children's
93
+ slugs), so no site field can use that name. It orders the editor's tree, the Inside picker
94
+ and `listDocuments(site, 'pages')`; the site's menus stay lists in Site Settings.
95
+ `caracms validate` warns on an entry that names no page, and `--fix` prunes it.
96
+
83
97
  ## Hosting
84
98
 
85
99
  caracms runs on any host that keeps one Node server running behind HTTPS: Railway,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caracms",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,7 +33,9 @@ import { fieldWarnings, parseDay, richTextOptions, type Field, type Fields, type
33
33
  import { measureLockup } from '../measure.ts';
34
34
  import { isFileKey } from '../files.ts';
35
35
  import { readRichText } from '../rich-text.ts';
36
- import { savedForm } from '../serialize.ts';
36
+ import { applyKeyedEdits, savedForm } from '../serialize.ts';
37
+ import { isOrderedCollection, prunedOrder, unknownOrderEntries } from '../page-order.ts';
38
+ import { SITE_SETTINGS_FILE } from '../reserved.ts';
37
39
  import { SCHEMA_KEY, schemaRef } from '../schema-key.ts';
38
40
  import { sameAddress } from '../slug.ts';
39
41
  import { hiddenDocuments, hiddenPages } from '../links.ts';
@@ -331,7 +333,7 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
331
333
  else checkBlock(c, raw.block, 'block', null);
332
334
  return;
333
335
  case 'settings': {
334
- checkFields(c, own, raw, '', 'Site Settings', 0, [SCHEMA_KEY, 'title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects', 'holds']);
336
+ checkFields(c, own, raw, '', 'Site Settings', 0, [SCHEMA_KEY, 'title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects', 'pageOrder', 'holds']);
335
337
  if (raw.holds !== undefined) error(c, 'holds', HOLDS_ON_A_PAGE);
336
338
  if (raw.editors !== undefined && !strings(raw.editors)) error(c, 'editors', `Expected a list of email addresses, found ${what(raw.editors)}.`);
337
339
  if (raw.brand !== undefined) {
@@ -354,6 +356,9 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
354
356
  if (raw.redirects !== undefined && !(Array.isArray(raw.redirects) && raw.redirects.every((r) => isObject(r) && typeof r.from === 'string' && typeof r.to === 'string'))) {
355
357
  error(c, 'redirects', 'Expected a list of { "from": "/old", "to": "/new" }.');
356
358
  }
359
+ if (raw.pageOrder !== undefined && !(isObject(raw.pageOrder) && Object.values(raw.pageOrder).every(strings))) {
360
+ error(c, 'pageOrder', 'Expected { "<parent path>": ["slug", …] }.');
361
+ }
357
362
  return;
358
363
  }
359
364
  }
@@ -450,6 +455,19 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
450
455
  }
451
456
  }
452
457
 
458
+ // Arrange pages (Decision 6): a saved order's entry that names no page or group (the reader ignores it).
459
+ const order = savedOrderOf(site);
460
+ if (order) {
461
+ for (const u of unknownOrderEntries(order, orderedPaths(site, entries))) {
462
+ const where = u.key === '' ? 'the top level' : `/${u.key}`;
463
+ report.warnings.push({
464
+ file: SITE_SETTINGS_FILE,
465
+ path: `pageOrder[${JSON.stringify(u.key)}]`,
466
+ message: u.slug === null ? `The Pages tree's order names ${where}, where there are no pages. \`caracms validate --fix\` prunes it.` : `The Pages tree's order names "${u.slug}" in ${where}, where there's no page or group by that name. \`caracms validate --fix\` prunes it.`,
467
+ });
468
+ }
469
+ }
470
+
453
471
  for (const change of schemaFileChanges(site)) {
454
472
  report.warnings.push({
455
473
  file: change.file,
@@ -460,6 +478,38 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
460
478
  return report;
461
479
  }
462
480
 
481
+ /** The Pages collection's paths (page-order.ts → isOrderedCollection). */
482
+ const orderedPaths = (site: Pick<ResolvedSite, 'collections'>, entries: readonly DocumentEntry[]) =>
483
+ entries.filter((e) => isOrderedCollection(site.collections[e.collection]!)).map((e) => e.path);
484
+
485
+ /** Site Settings' `pageOrder` as the file has it, or null (no file, no key, or it doesn't parse). */
486
+ function savedOrderOf(site: Pick<ResolvedSite, 'root'>): Record<string, string[]> | null {
487
+ try {
488
+ const raw = JSON.parse(readFileSync(join(site.root, SITE_SETTINGS_FILE), 'utf8')) as unknown;
489
+ const order = isObject(raw) ? raw.pageOrder : undefined;
490
+ if (!isObject(order) || !Object.values(order).every(strings)) return null;
491
+ return order as Record<string, string[]>;
492
+ } catch {
493
+ return null;
494
+ }
495
+ }
496
+
497
+ /** Arrange pages (Decision 6): `--fix` takes the saved order's entries that name nothing out of Site Settings. Returns how many went. */
498
+ export async function prunePageOrder(site: Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>): Promise<number> {
499
+ const order = savedOrderOf(site);
500
+ const settings = Object.values(site.collections).find((c) => c.kind === 'settings');
501
+ if (!order || !settings) return 0;
502
+ const { order: kept, pruned } = prunedOrder(order, orderedPaths(site, await listDocuments(site)));
503
+ if (pruned === 0) return 0;
504
+ const file = join(site.root, SITE_SETTINGS_FILE);
505
+ const text = readFileSync(file, 'utf8');
506
+ const raw = JSON.parse(text) as Record<string, unknown>;
507
+ writeFileSync(file, applyKeyedEdits(text, { title: typeof raw.title === 'string' ? raw.title : '', data: { pageOrder: kept } }, settings));
508
+ return pruned;
509
+ }
510
+
511
+ const pruneReport = (n: number) => (n ? `caracms validate --fix:\n pruned ${n} page-order ${n === 1 ? 'entry' : 'entries'} (${SITE_SETTINGS_FILE})\n` : '');
512
+
463
513
  const REFORMAT_MESSAGE = "This file isn't laid out the way the editor saves it (two-space JSON, a trailing newline), so the editor's next save would reformat it and mix the two. `caracms validate --fix` rewrites it the editor's way, changing no values.";
464
514
 
465
515
  /**
@@ -504,7 +554,7 @@ export function formatReport(report: Report): string {
504
554
  export async function command(root: string, options: { fix?: boolean; json?: boolean } = {}): Promise<{ code: number; out: string; err?: string }> {
505
555
  const site = await loadSite(root);
506
556
  let fixed = '';
507
- if (options.fix) fixed = schemasReport(await runSchemas(site)) + reformatReport(await reformatContent(site));
557
+ if (options.fix) fixed = schemasReport(await runSchemas(site)) + reformatReport(await reformatContent(site)) + pruneReport(await prunePageOrder(site));
508
558
  const report = await validateSite(site);
509
559
  const code = report.errors.length ? 1 : 0;
510
560
  if (options.json) return { code, out: `${JSON.stringify(report, null, 2)}\n`, err: options.fix ? fixed : undefined };
@@ -150,6 +150,7 @@ export function collectionSchema(site: Pick<ResolvedSite, 'blocks'> & Partial<Pi
150
150
  googleTag: string,
151
151
  formRecipients: { type: 'object', additionalProperties: strings },
152
152
  redirects: { type: 'array', items: closed({ from: string, to: string }, ['from', 'to']) },
153
+ pageOrder: { type: 'object', additionalProperties: strings, description: "The Pages tree's order in the editor: a parent's path (\"\" for the top level) → its children's slugs. Written by Arrange pages." },
153
154
  ...own,
154
155
  },
155
156
  ['title'],
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
@@ -39,6 +41,8 @@ import { parseSource } from './json-source.ts';
39
41
  import { hasHeld, heldBases, type HeldBase } from './held.ts';
40
42
  import { leftBehindMessage, leftBehindRoutes, ownRoutedCollections } from './code-routes.ts';
41
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';
42
46
 
43
47
  // Posts and tags, Decision 14: a route's reader on the disk (read.ts).
44
48
  export { contentReader } from './read.ts';
@@ -82,6 +86,11 @@ export interface DocumentEntry {
82
86
  */
83
87
  hidden?: true;
84
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;
85
94
  /**
86
95
  * Collections under pages (Decision 11): set when `hiddenBy` is a page of another collection,
87
96
  * the page that holds this document's collection (`pages`). Absent: `hiddenBy` is in this one.
@@ -251,7 +260,9 @@ export async function listDocuments(
251
260
  const paths = await documentPaths(collection, source);
252
261
  const entries = (await Promise.all(paths.map((path) => entryFor(site, collection, path, source)))).filter((e) => e !== null);
253
262
  if (collection.nested) hideChildren(entries);
254
- 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)));
255
266
  }
256
267
  // Collections under pages (Decision 7): a held collection's addresses come from its holder's listing.
257
268
  const held = names.map((n) => site.collections[n]!).filter((c) => c.heldBy);
@@ -276,6 +287,21 @@ export async function listDocuments(
276
287
  return all;
277
288
  }
278
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
+
279
305
  /** A held document's address and hidden mark from its collection's resolved base (held.ts). */
280
306
  function placeHeld(entry: DocumentEntry, held: HeldBase): void {
281
307
  entry.url = held.base === null ? null : held.base + entry.path;
@@ -409,7 +435,7 @@ function inspect(
409
435
  }
410
436
  const entry: DocumentEntry = { ...base, title, label: title, warning: null };
411
437
  // M9 Decision 7: the home page is never hidden.
412
- 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;
413
439
  if ((document.kind === undefined || document.kind === 'sections') && document.holds !== undefined) entry.holds = document.holds;
414
440
  if (document.kind === undefined || document.kind === 'sections') {
415
441
  entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
@@ -450,10 +476,11 @@ export type PageTreeNode =
450
476
  /**
451
477
  * A nested collection's entries as a tree that mirrors their paths. A page with
452
478
  * pages under it (`about.json` beside `about/`) is one node with children; a folder
453
- * with no page is a group. Each level is ordered Home (`index`) first, then
454
- * 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.
455
482
  */
456
- export function pageTree(entries: readonly DocumentEntry[]): PageTreeNode[] {
483
+ export function pageTree(entries: readonly DocumentEntry[], order: PageOrder = {}): PageTreeNode[] {
457
484
  interface Draft {
458
485
  path: string;
459
486
  entry?: DocumentEntry;
@@ -475,17 +502,15 @@ export function pageTree(entries: readonly DocumentEntry[]): PageTreeNode[] {
475
502
  node.entry = entry;
476
503
  }
477
504
  const build = (draft: Draft): PageTreeNode[] =>
478
- [...draft.children.values()]
479
- .map((d): PageTreeNode => {
505
+ orderSiblings(
506
+ [...draft.children.values()].map((d): PageTreeNode => {
480
507
  const children = build(d);
481
508
  if (d.entry) return { kind: 'page', path: d.path, label: d.entry.label, entry: d.entry, children };
482
509
  return { kind: 'group', path: d.path, label: titleCase(d.path.split('/').pop()!), children };
483
- })
484
- .sort((a, b) => {
485
- if (a.path === 'index' || a.path.endsWith('/index')) return -1;
486
- if (b.path === 'index' || b.path.endsWith('/index')) return 1;
487
- return byLabel(a, b);
488
- });
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
+ );
489
514
  return build(top);
490
515
  }
491
516
 
@@ -493,10 +518,6 @@ function flattenTree(nodes: PageTreeNode[]): DocumentEntry[] {
493
518
  return nodes.flatMap((n) => [...(n.kind === 'page' ? [n.entry] : []), ...flattenTree(n.children)]);
494
519
  }
495
520
 
496
- const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
497
- function byLabel(a: { label: string; path: string }, b: { label: string; path: string }): number {
498
- return collator.compare(a.label, b.label) || collator.compare(a.path, b.path);
499
- }
500
521
 
501
522
  // ── The catch-all's reader (collections under pages, Decision 14) ────────────────
502
523
 
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 {
@@ -128,14 +129,16 @@ export interface SettingsBrand {
128
129
  export interface SettingsDocument {
129
130
  kind: 'settings';
130
131
  title: string;
131
- 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 };
132
133
  }
133
134
 
134
135
  /** M6: Site Settings' search and sharing keys, parsed, each only when `raw` has it. */
135
- 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 } {
136
137
  return {
137
138
  // Pre-M7 item 8: the redirects changing an address adds (redirects.ts).
138
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) } : {}),
139
142
  ...(Object.hasOwn(raw, 'seo') ? { seo: parseSeo(raw.seo) } : {}),
140
143
  ...(Object.hasOwn(raw, 'googleTag') ? { googleTag: parseGoogleTag(raw.googleTag) } : {}),
141
144
  ...(Object.hasOwn(raw, 'formRecipients') ? { formRecipients: parseFormRecipients(raw.formRecipients) } : {}),
@@ -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;