caracms 0.3.3 → 0.4.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 (55) hide show
  1. package/README.md +59 -3
  2. package/bin/caracms.mjs +30 -6
  3. package/bin/gate.mjs +157 -6
  4. package/bin/templates/catch-all.astro.txt +10 -9
  5. package/package.json +1 -1
  6. package/skills/caracms-adopt/SKILL.md +21 -4
  7. package/skills/caracms-blocks/SKILL.md +36 -13
  8. package/skills/caracms-content/SKILL.md +27 -6
  9. package/skills/caracms-tina/SKILL.md +53 -11
  10. package/skills/caracms-tina/field-kinds.md +2 -0
  11. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  12. package/src/cli/validate.ts +51 -10
  13. package/src/code-routes.ts +26 -0
  14. package/src/components/Head.astro +5 -5
  15. package/src/components/RenderDocument.astro +3 -3
  16. package/src/components/sitemap.xml.ts +5 -4
  17. package/src/config.ts +57 -11
  18. package/src/content-schema.ts +5 -2
  19. package/src/content.ts +148 -8
  20. package/src/document.ts +8 -0
  21. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  22. package/src/editor/client/documents/actions.ts +61 -16
  23. package/src/editor/client/documents/dialogs.tsx +104 -15
  24. package/src/editor/client/editor.ts +22 -1
  25. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  26. package/src/editor/client/nav/DocRow.tsx +8 -1
  27. package/src/editor/client/nav/tree.ts +26 -1
  28. package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
  29. package/src/editor/client/pane/parts.tsx +14 -1
  30. package/src/editor/routes/move.ts +1 -1
  31. package/src/editor/server/admin-props.ts +19 -1
  32. package/src/editor/server/create.ts +13 -2
  33. package/src/editor/server/drafts-store.ts +24 -9
  34. package/src/editor/server/move.ts +266 -81
  35. package/src/editor/styles/editor.css +5 -0
  36. package/src/editor/styles/levels/collections.css +2 -0
  37. package/src/editor/styles/levels/m5a-documents.css +15 -0
  38. package/src/editor/types.ts +31 -5
  39. package/src/held.ts +116 -0
  40. package/src/index.ts +4 -2
  41. package/src/live/README.md +3 -2
  42. package/src/live/auth/middleware.ts +11 -0
  43. package/src/live/autosaves.ts +88 -1
  44. package/src/live/cloud.ts +131 -0
  45. package/src/live/drafts-store.ts +122 -87
  46. package/src/live/editor.ts +22 -4
  47. package/src/live/git.ts +63 -12
  48. package/src/live/github.ts +69 -7
  49. package/src/live/mode.ts +42 -6
  50. package/src/live/runtime.ts +18 -2
  51. package/src/redirect-middleware.ts +3 -0
  52. package/src/redirects.ts +32 -1
  53. package/src/relink.ts +40 -9
  54. package/src/serialize.ts +1 -1
  55. package/src/slug.ts +15 -4
@@ -126,13 +126,16 @@ function document(collection: ResolvedCollection, properties: Record<string, Sch
126
126
  }
127
127
 
128
128
  /** A collection's schema (`schemas/<collection>.schema.json`, or the reserved two's names). */
129
- export function collectionSchema(site: Pick<ResolvedSite, 'blocks'>, collection: ResolvedCollection): Schema {
129
+ export function collectionSchema(site: Pick<ResolvedSite, 'blocks'> & Partial<Pick<ResolvedSite, 'collections'>>, collection: ResolvedCollection): Schema {
130
130
  const own = Object.fromEntries(Object.entries(collection.fields ?? {}).map(([key, f]) => [key, fieldSchema(f)]));
131
131
  switch (collection.kind) {
132
132
  case 'sections':
133
133
  // M9 Decision 7: `hidden: true` keeps the page off the site; absent, it's shown.
134
134
  const hidden: Schema = { description: 'Hidden from the site: not served, not in the sitemap, still edited in /admin.', const: true };
135
- return document(collection, { hidden, ...own, blocks: { type: 'array', items: { anyOf: [{ $ref: '#/$defs/shared' }, anyBlock(site)] } }, seo }, ['title', 'blocks'], { shared: sharedEntry, ...blockDefs(site) });
135
+ // Collections under pages (Decision 5): a page of a collection some collection's heldBy names may hold one of them.
136
+ const holdable = Object.values(site.collections ?? {}).filter((c) => c.heldBy === collection.name).map((c) => c.name);
137
+ const holds: Schema | null = holdable.length ? { description: 'The collection this page holds: its documents are at this page\'s address + their slug.', enum: holdable } : null;
138
+ return document(collection, { hidden, ...(holds ? { holds } : {}), ...own, blocks: { type: 'array', items: { anyOf: [{ $ref: '#/$defs/shared' }, anyBlock(site)] } }, seo }, ['title', 'blocks'], { shared: sharedEntry, ...blockDefs(site) });
136
139
  case 'fields':
137
140
  return document(collection, { ...own, seo }, ['title']);
138
141
  case 'shared':
package/src/content.ts CHANGED
@@ -36,6 +36,9 @@ import { isBuiltInColumn } from './columns.ts';
36
36
  import { fieldsCover, sectionsCover, type CardCover } from './cards.ts';
37
37
  import { own, parseAnyDocument, type AnyDocument, type DocumentKind } from './document.ts';
38
38
  import { parseSource } from './json-source.ts';
39
+ import { hasHeld, heldBases, type HeldBase } from './held.ts';
40
+ import { leftBehindMessage, leftBehindRoutes, ownRoutedCollections } from './code-routes.ts';
41
+ import { sameAddress } from './slug.ts';
39
42
 
40
43
  // Posts and tags, Decision 14: a route's reader on the disk (read.ts).
41
44
  export { contentReader } from './read.ts';
@@ -79,6 +82,16 @@ export interface DocumentEntry {
79
82
  */
80
83
  hidden?: true;
81
84
  hiddenBy?: string;
85
+ /**
86
+ * Collections under pages (Decision 11): set when `hiddenBy` is a page of another collection,
87
+ * the page that holds this document's collection (`pages`). Absent: `hiddenBy` is in this one.
88
+ */
89
+ hiddenByCollection?: string;
90
+ /**
91
+ * Collections under pages (Decision 5): a page's `"holds"`, the collection it holds (held.ts).
92
+ * Read from a file that can't be opened too, as far as it can be, so a broken holder is named.
93
+ */
94
+ holds?: string;
82
95
  /** A section builder's shared-block entries, by shared block path, in block order, once each (Decision 8). Absent otherwise. */
83
96
  uses?: string[];
84
97
  /** A shared block: its block's type (`''` when it has none). Absent otherwise. */
@@ -220,13 +233,17 @@ export function resolveDocumentFile(site: Pick<ResolvedSite, 'collections'>, col
220
233
  return file;
221
234
  }
222
235
 
223
- /** Every document in one collection, or in all of them (in config order), with warnings, in the editor's order. */
236
+ /**
237
+ * Every document in one collection, in several (in the order given), or in all of them (in
238
+ * config order), with warnings, in the editor's order. A held collection's documents have
239
+ * their resolved addresses (held.ts), whichever collections are listed.
240
+ */
224
241
  export async function listDocuments(
225
242
  site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>,
226
- collectionName?: string,
243
+ collectionName?: string | readonly string[],
227
244
  source: ContentSource = diskSource,
228
245
  ): Promise<DocumentEntry[]> {
229
- const names = collectionName ? [collectionName] : Object.keys(site.collections);
246
+ const names = collectionName === undefined ? Object.keys(site.collections) : typeof collectionName === 'string' ? [collectionName] : [...collectionName];
230
247
  const all: DocumentEntry[] = [];
231
248
  for (const name of names) {
232
249
  const collection = own(site.collections, name);
@@ -236,7 +253,20 @@ export async function listDocuments(
236
253
  if (collection.nested) hideChildren(entries);
237
254
  all.push(...(collection.nested ? flattenTree(pageTree(entries)) : entries.sort(byLabel)));
238
255
  }
239
- if (!collectionName) {
256
+ // Collections under pages (Decision 7): a held collection's addresses come from its holder's listing.
257
+ const held = names.map((n) => site.collections[n]!).filter((c) => c.heldBy);
258
+ if (held.length) {
259
+ const holding: DocumentEntry[] = [];
260
+ for (const by of new Set(held.map((c) => c.heldBy!))) {
261
+ holding.push(...(names.includes(by) ? all.filter((e) => e.collection === by) : await listDocuments(site, by, source)));
262
+ }
263
+ const bases = heldBases(site.collections, holding);
264
+ for (const entry of all) {
265
+ const base = own(bases, entry.collection);
266
+ if (base) placeHeld(entry, base);
267
+ }
268
+ }
269
+ if (collectionName === undefined) {
240
270
  // Decision 8's "used on": every document whose block list has an entry for the shared block.
241
271
  for (const entry of all) {
242
272
  if (entry.kind !== 'shared') continue;
@@ -246,6 +276,44 @@ export async function listDocuments(
246
276
  return all;
247
277
  }
248
278
 
279
+ /** A held document's address and hidden mark from its collection's resolved base (held.ts). */
280
+ function placeHeld(entry: DocumentEntry, held: HeldBase): void {
281
+ entry.url = held.base === null ? null : held.base + entry.path;
282
+ // Decision 11: a hidden holder hides its documents, as a hidden page hides the pages under it.
283
+ if (held.hidden && held.holder) {
284
+ entry.hidden = true;
285
+ entry.hiddenBy = held.holder.path;
286
+ entry.hiddenByCollection = held.holder.collection;
287
+ }
288
+ }
289
+
290
+ /** A held collection's resolved base, from its holder collection's listing through `source`. */
291
+ export async function heldBaseOf(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, collectionName: string, source: ContentSource = diskSource): Promise<HeldBase | null> {
292
+ const collection = own(site.collections, collectionName);
293
+ if (!collection?.heldBy) return null;
294
+ return heldBases({ [collection.name]: collection }, await listDocuments(site, collection.heldBy, source))[collection.name] ?? null;
295
+ }
296
+
297
+ /** Every held collection's resolved base (held.ts), from the listings of the collections that hold them; `{}` on a site with none. */
298
+ export async function heldBasesOf(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, source: ContentSource = diskSource): Promise<Record<string, HeldBase>> {
299
+ if (!hasHeld(site.collections)) return {};
300
+ const by = [...new Set(Object.values(site.collections).flatMap((c) => (c.heldBy ? [c.heldBy] : [])))];
301
+ const holding = (await Promise.all(by.map((name) => listDocuments(site, name, source)))).flat();
302
+ return heldBases(site.collections, holding);
303
+ }
304
+
305
+ /**
306
+ * A document's public address as its listing gives it: `documentUrl` for a collection with a
307
+ * prefix, the holder's address + `/` + the path for a held one (null while it isn't held).
308
+ */
309
+ export async function documentAddress(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, collectionName: string, path: string, source: ContentSource = diskSource): Promise<string | null> {
310
+ const collection = own(site.collections, collectionName);
311
+ if (!collection) return null;
312
+ if (!collection.heldBy) return documentUrl(collection, path);
313
+ const held = await heldBaseOf(site, collectionName, source);
314
+ return held?.base ? held.base + path : null;
315
+ }
316
+
249
317
  /** M9 Decision 7: a hidden page's children are hidden with it; `hiddenBy` names the topmost hidden page above. */
250
318
  function hideChildren(entries: DocumentEntry[]): void {
251
319
  const hidden = new Set(entries.filter((e) => e.hidden).map((e) => e.path));
@@ -279,6 +347,10 @@ export async function readDocument(
279
347
  if (text === null) throw new DocumentError('not-found', `${collection.label}: no document "${path}".`);
280
348
  const { entry, document } = inspect(site, collection, path, text);
281
349
  if (entry.warning || !document) throw new DocumentError(entry.warning!.code, entry.warning!.message);
350
+ if (collection.heldBy) {
351
+ const held = await heldBaseOf(site, collection.name, source);
352
+ if (held) placeHeld(entry, held);
353
+ }
282
354
  return { entry, text, document };
283
355
  }
284
356
 
@@ -312,10 +384,12 @@ function inspect(
312
384
  url: documentUrl(collection, path),
313
385
  };
314
386
  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
- });
387
+ // Decision 13: a broken holder is named, so its `"holds"` is read from the text as far as it can be.
388
+ const holdsIn = () => (collection.kind === 'sections' ? /"holds"\s*:\s*"([^"\\]*)"/.exec(text)?.[1] : undefined);
389
+ const warn = (code: WarningCode, message: string, title = '') => {
390
+ const holds = holdsIn();
391
+ return { entry: { ...base, title, label: title || fileLabel, warning: { code, message }, ...(holds !== undefined ? { holds } : {}) }, document: null };
392
+ };
319
393
 
320
394
  if (collection.nested && path.endsWith('/index')) {
321
395
  const parent = path.slice(0, -'/index'.length);
@@ -336,6 +410,7 @@ function inspect(
336
410
  const entry: DocumentEntry = { ...base, title, label: title, warning: null };
337
411
  // M9 Decision 7: the home page is never hidden.
338
412
  if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = true;
413
+ if ((document.kind === undefined || document.kind === 'sections') && document.holds !== undefined) entry.holds = document.holds;
339
414
  if (document.kind === undefined || document.kind === 'sections') {
340
415
  entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
341
416
  } else if (document.kind === 'shared') {
@@ -422,3 +497,68 @@ const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true })
422
497
  function byLabel(a: { label: string; path: string }, b: { label: string; path: string }): number {
423
498
  return collator.compare(a.label, b.label) || collator.compare(a.path, b.path);
424
499
  }
500
+
501
+ // ── The catch-all's reader (collections under pages, Decision 14) ────────────────
502
+
503
+ /** A public page the site's catch-all serves: its collection, its path there, and its address. */
504
+ export interface AddressedDocument {
505
+ collection: string;
506
+ path: string;
507
+ /** `/`, `/about`, `/journal/a-day-on-the-ferry`: no trailing slash, as the listing gives it. */
508
+ url: string;
509
+ }
510
+
511
+ /** What the reader reads: the site, and the routes in its `src/pages/` (the integration sets `pageRoutes`). */
512
+ type CatchAllSite = Pick<ResolvedSite, 'collections' | 'root' | 'blocks'> & Partial<Pick<ResolvedSite, 'pageRoutes'>>;
513
+
514
+ const logged = new Set<string>();
515
+ /** The build's and dev's log, once per sentence per process (Decision 13: logged, never a failed build). */
516
+ function logOnce(message: string): void {
517
+ if (logged.has(message)) return;
518
+ logged.add(message);
519
+ console.warn(`[caracms] ${message}`);
520
+ }
521
+
522
+ /**
523
+ * Every public page of every collection with an address, for the site's one catch-all route
524
+ * (`src/pages/[...path].astro`'s `getStaticPaths`): Pages, every held collection's documents at
525
+ * their holder's address, and every prefix collection's (`/tags/<slug>`), in config order then
526
+ * the editor's. Left out: a file that can't be opened, a hidden page (and the documents of a
527
+ * hidden holder), a held collection no page holds, and a prefix collection whose base has its
528
+ * own route (`src/pages/tags/[slug].astro`), which that route serves. A holder problem or a
529
+ * route left at a held base is logged once (`caracms validate` reports both as errors).
530
+ * `source`: where the files come from (the disk by default, as a build reads them).
531
+ */
532
+ export async function addressedDocuments(site: CatchAllSite, source: ContentSource = diskSource): Promise<AddressedDocument[]> {
533
+ const collections = Object.values(site.collections);
534
+ const routes = site.pageRoutes ?? [];
535
+ const ownRouted = new Set(ownRoutedCollections(routes, collections).map((c) => c.name));
536
+ const names = collections.filter((c) => (c.urlBase !== null || c.heldBy) && !ownRouted.has(c.name)).map((c) => c.name);
537
+ const entries = await listDocuments(site, names, source);
538
+ if (hasHeld(site.collections)) {
539
+ const held: { name: string; label: string; base: string; holder: string }[] = [];
540
+ for (const base of Object.values(await heldBasesOf(site, source))) {
541
+ if (base.problem) logOnce(base.problem);
542
+ const c = site.collections[base.collection]!;
543
+ if (base.base && base.holder) held.push({ name: c.name, label: c.label, base: base.base, holder: base.holder.label });
544
+ }
545
+ for (const left of leftBehindRoutes(routes, held)) logOnce(leftBehindMessage(left));
546
+ }
547
+ return entries.filter((e): e is DocumentEntry & { url: string } => e.url !== null && !e.warning && !e.hidden).map(({ collection, path, url }) => ({ collection, path, url }));
548
+ }
549
+
550
+ /**
551
+ * The document a request's path is (`/journal/a-day-on-the-ferry`, with or without a trailing
552
+ * slash, URL-encoded or not), for a catch-all that renders on demand; null when no public page
553
+ * is there (`addressedDocuments` says which are).
554
+ */
555
+ export async function documentAt(site: CatchAllSite, pathname: string, source: ContentSource = diskSource): Promise<{ collection: string; path: string } | null> {
556
+ let at: string;
557
+ try {
558
+ at = sameAddress(decodeURIComponent(pathname));
559
+ } catch {
560
+ return null;
561
+ }
562
+ const found = (await addressedDocuments(site, source)).find((d) => sameAddress(d.url) === at);
563
+ return found ? { collection: found.collection, path: found.path } : null;
564
+ }
package/src/document.ts CHANGED
@@ -67,6 +67,13 @@ export interface SiteDocument {
67
67
  * the key out of the file. The home page (`index`) can't be hidden (content.ts reads it as shown).
68
68
  */
69
69
  hidden?: boolean;
70
+ /**
71
+ * Collections under pages (Decision 5): the collection this page holds (the file's
72
+ * top-level `"holds"`, after `title` and `hidden`): its documents are at this page's
73
+ * address + `/` + their slug (held.ts). Edited in the file only, never by the editor,
74
+ * whose saves keep it where it is.
75
+ */
76
+ holds?: string;
70
77
  /**
71
78
  * M6c (G2): the page fields' parsed values, keyed by field, every field of the
72
79
  * collection's `fields` present (the file's top-level keys after `title`). Present
@@ -225,6 +232,7 @@ export function parseDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>):
225
232
  blocks: rawBlocks.map((b, index) => parseBlock(b, index, site)),
226
233
  ...seoOf(doc),
227
234
  ...(doc.hidden === true ? { hidden: true } : {}),
235
+ ...(typeof doc.holds === 'string' ? { holds: doc.holds } : {}),
228
236
  };
229
237
  }
230
238
 
@@ -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
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
21
21
  import { INDEX_MESSAGE, NO_ADDRESS_MESSAGE, RESERVED_SLUG, addressProblem, slugify } from '../../../slug.ts';
22
+ import { documentsNoun, holdsMessage } from '../../../held.ts';
22
23
  import { useEditor, useStore } from '../context.tsx';
23
24
  import type { Editor } from '../editor.ts';
24
25
  import { blockIcon } from '../block-list/summary.ts';
@@ -27,7 +28,7 @@ import { Icon } from '../shell/Icons.tsx';
27
28
  import { createStore, type Store } from '../state/store.ts';
28
29
  import type { CreateDeleteRefusal, DocRef, ListedDocument } from '../../types.ts';
29
30
  import { INSIDE_CLOSE, InsidePicker } from './InsidePicker.tsx';
30
- import { addressFor, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
31
+ import { addressFor, addressesMoving, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
31
32
  import { filesLabel, inboxFor } from '../inbox/store.ts';
32
33
  import { ReplaceDialog } from '../media/ReplaceDialog.tsx';
33
34
 
@@ -297,7 +298,18 @@ function NewDocument(props: { collection: string; inside?: string; at?: string }
297
298
  /**
298
299
  * A document's new address: for nested Pages the group it sits in ("Inside", New page's
299
300
  * select, with New group…) and the address's last part, shown whole as it's typed, with
300
- * New page's rules checked before asking the server. Pages under it stay where they are.
301
+ * New page's rules checked before asking the server.
302
+ *
303
+ * **Everything under it moves with it** (collections under pages, Decision 9): the pages
304
+ * under it at any depth, and the documents of a collection it (or a page under it) holds.
305
+ * Then the dialog lists every address that changes ("Its 1 page moves with it",
306
+ * "Its 24 journal entries move with it", the collection's own word for its documents), warns about a route of the site's own code under
307
+ * the old address (it doesn't move), and has one tick for the lot. A page with nothing
308
+ * under it keeps the single-page dialog.
309
+ *
310
+ * "Leave a redirect from …" ("Leave redirects from the old addresses" for several), ticked
311
+ * to start with, says whether the old addresses redirect; it shows only when there's one to
312
+ * leave (a live address, not one an earlier move since the last Publish already redirects).
301
313
  */
302
314
  function MoveDocument({ docRef }: { docRef: DocRef }) {
303
315
  const editor = useEditor();
@@ -311,8 +323,9 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
311
323
  const [part, setPart] = useState(docRef.path.slice(slash + 1));
312
324
  const [error, setError] = useState<string | null>(null);
313
325
  const [busy, setBusy] = useState(false);
314
- // Every page but this one: a page can't go under itself.
315
- const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents).filter((o) => o.path !== docRef.path) : []), [collection, documents, docRef.path]);
326
+ const [redirect, setRedirect] = useState(true);
327
+ // Every page but this one and the pages under it: a page can't go under itself.
328
+ const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents).filter((o) => o.path !== docRef.path && !o.path.startsWith(`${docRef.path}/`)) : []), [collection, documents, docRef.path]);
316
329
  if (!doc || !collection || !doc.url) return null;
317
330
  const nested = collection.nested;
318
331
  const newGroup = nested && inside === NEW_GROUP;
@@ -323,8 +336,10 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
323
336
  const url = addressFor(collection, path);
324
337
  const base = addressFor(collection, folder ? `${folder}/` : '') ?? '/';
325
338
  const same = path === docRef.path;
326
- const others = documents.filter((d) => !(d.collection === docRef.collection && d.path === docRef.path));
327
- // New page's rules, before asking the server (it applies the same).
339
+ const moving = addressesMoving(editor.site.collections, docRef, path, documents);
340
+ const many = moving.length > 1;
341
+ const others = documents.filter((d) => !moving.some((m) => m.doc === d));
342
+ // New page's rules, before asking the server (it applies the same, and checks every address that changes).
328
343
  const local = (() => {
329
344
  if (same) return null;
330
345
  if (newGroup && !slugify(group)) return group.trim() ? NO_ADDRESS_MESSAGE : null;
@@ -334,21 +349,38 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
334
349
  })();
335
350
  const shown = error ?? local;
336
351
  const blocked = same || !last || (newGroup && !slugify(group)) || !!local || busy;
337
- // Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from.
338
- const pending = (waiting ?? []).find((w) => w.collection === docRef.collection && w.path === docRef.path && w.change === 'new');
352
+ // Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from,
353
+ // and its redirect from there is there already, following this move.
354
+ const pendingOf = (d: ListedDocument) => (waiting ?? []).find((w) => w.collection === d.collection && w.path === d.path && (w.change === 'new' || !!w.movedFrom));
355
+ const pending = pendingOf(doc);
339
356
  const liveAddress = pending ? (pending.movedFrom ?? null) : doc.url;
340
- const kids = nested ? pagesUnder(collection.name, docRef.path, documents) : [];
357
+ const choose = many ? moving.some((m) => !pendingOf(m.doc)) : !pending;
341
358
  const host = editor.site.host;
342
359
  const confirm = async () => {
343
360
  if (blocked) return;
344
361
  setBusy(true);
345
362
  setError(null);
346
- const answer = await moveDocument(editor, docRef, path, doc.label);
363
+ const answer = await moveDocument(editor, docRef, path, doc.label, !choose || redirect);
347
364
  setBusy(false);
348
365
  if (answer.ok) closeDialog(editor);
349
366
  else setError(answer.error);
350
367
  };
351
- const intro = liveAddress ? `${liveAddress} will redirect here.${editor.mode === 'live' ? ' Goes live at the next Publish.' : ''}` : null;
368
+ const publish = editor.mode === 'live' ? ' Goes live at the next Publish.' : '';
369
+ const intro = !many && liveAddress && !choose ? `${liveAddress} will redirect here.${publish}` : null;
370
+ // What moves with it: the pages under it, then each held collection's documents.
371
+ const under = moving.filter((m) => !m.held && m.doc !== doc);
372
+ const heldBy = new Map<string, number>();
373
+ for (const m of moving) if (m.held) heldBy.set(m.doc.collection, (heldBy.get(m.doc.collection) ?? 0) + 1);
374
+ const labelOf = (name: string) => editor.site.collections.find((c) => c.name === name)?.label ?? name;
375
+ // "Its 1 page moves with it." "Its 24 journal entries move with it." "Its 2 pages and 24 journal entries move with it."
376
+ const heldParts = [...heldBy].map(([name, n]) => `${n} ${n === 1 ? singular({ kind: 'sections', nested: false, label: labelOf(name) }) : documentsNoun(labelOf(name))}`);
377
+ const parts = [...(under.length ? [`${under.length} page${under.length === 1 ? '' : 's'}`] : []), ...heldParts];
378
+ if (parts.length) parts[0] = `Its ${parts[0]}`;
379
+ const one = moving.length === 2 && parts.length === 1;
380
+ const withIt = `${andList(parts)} move${one ? 's' : ''} with it.`;
381
+ // A route of the site's own code under the old address stays where it is (Decision 10).
382
+ const oldBase = `${doc.url.replace(/\/+$/, '')}/`;
383
+ const routes = many ? (editor.site.codeRoutes ?? []).filter((r) => r.url.toLowerCase().startsWith(oldBase.toLowerCase())) : [];
352
384
  return (
353
385
  <Frame label="move">
354
386
  <form
@@ -408,10 +440,49 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
408
440
  </p>
409
441
  )}
410
442
  </div>
411
- {kids.length ? (
412
- <p className="field-help" data-kids={kids.length}>
413
- {andList(kids.map((k) => k.label))} stay{kids.length === 1 ? 's' : ''} where {kids.length === 1 ? 'it is' : 'they are'}, at {doc.url}/…
414
- </p>
443
+ {many ? (
444
+ <div className="field" data-moving={moving.length}>
445
+ <p className="field-help">
446
+ {withIt} {same ? 'The addresses now:' : 'The new addresses:'}
447
+ </p>
448
+ <ul className="mv-list" aria-label="Every address that changes" tabIndex={0}>
449
+ {moving.map((m) => (
450
+ <li key={`${m.doc.collection}/${m.doc.path}`} className="mono" data-to={m.toUrl ?? ''}>
451
+ {same ? m.doc.url : (m.toUrl ?? '…')}
452
+ </li>
453
+ ))}
454
+ </ul>
455
+ {routes.length ? (
456
+ <p className="field-help" data-routes={routes.length}>
457
+ <Icon name="warn" size="xs" /> {andList(routes.map((r) => r.file))} {routes.length === 1 ? 'is' : 'are'} the site’s own code under {doc.url}, so {routes.length === 1 ? 'it stays' : 'they stay'} where {routes.length === 1 ? 'it is' : 'they are'}.
458
+ </p>
459
+ ) : null}
460
+ </div>
461
+ ) : null}
462
+ {choose ? (
463
+ <div className="field">
464
+ <label className="check">
465
+ <input type="checkbox" id="mv-redirect" checked={redirect} aria-describedby="mv-redirect-h" onChange={(e) => setRedirect(e.target.checked)} />
466
+ <span>
467
+ {many ? (
468
+ 'Leave redirects from the old addresses'
469
+ ) : (
470
+ <>
471
+ Leave a redirect from <span className="mono">{doc.url}</span>
472
+ </>
473
+ )}
474
+ </span>
475
+ </label>
476
+ <p className="field-help" id="mv-redirect-h">
477
+ {many
478
+ ? redirect
479
+ ? `Anyone with an old address lands on the new one.${publish}`
480
+ : 'Nothing will be at the old addresses: links to them from elsewhere will find no page.'
481
+ : redirect
482
+ ? `Anyone with the old address lands here.${publish}`
483
+ : `Nothing will be at ${doc.url}: links to it from elsewhere will find no page.`}
484
+ </p>
485
+ </div>
415
486
  ) : null}
416
487
  <div className="acts">
417
488
  <button type="button" className="btn" onClick={() => closeDialog(editor)}>
@@ -569,6 +640,24 @@ function DeleteDocument({ docRef }: { docRef: DocRef }) {
569
640
  const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
570
641
  const collection = editor.site.collections.find((c) => c.name === docRef.collection);
571
642
  if (!doc || !collection) return null;
643
+ // Collections under pages (Decision 12): a page that holds a collection isn't deleted, and says so instead of asking.
644
+ const held = doc.holds ? editor.site.collections.find((c) => c.name === doc.holds && c.heldBy === collection.name) : undefined;
645
+ if (held) {
646
+ return (
647
+ <Frame alert label="holds" describedBy="dlg-d">
648
+ <div className="icon-h" style={{ background: 'var(--status-warning-wash)', color: 'var(--status-warning)' }}>
649
+ <Icon name="warn" />
650
+ </div>
651
+ <h2 id="dlg-h">{doc.label} can’t be deleted</h2>
652
+ <p id="dlg-d">{holdsMessage(doc.label, held.label)}</p>
653
+ <div className="acts">
654
+ <button type="button" className="btn ink" data-act="holds-ok" onClick={() => closeDialog(editor)}>
655
+ OK
656
+ </button>
657
+ </div>
658
+ </Frame>
659
+ );
660
+ }
572
661
  const shared = collection.kind === 'shared';
573
662
  const kids = collection.nested ? pagesUnder(collection.name, doc.path, documents) : [];
574
663
  const host = editor.site.host;
@@ -31,6 +31,7 @@
31
31
  * dismissNotice(id) / signOut() / signInAgain()
32
32
  */
33
33
  import { addressOf, inboxAddress, inboxFromAddress, refFromAddress } from '../addresses.ts';
34
+ import { heldBases } from '../../held.ts';
34
35
  import {
35
36
  docId,
36
37
  ENDPOINTS,
@@ -298,6 +299,24 @@ export interface Editor {
298
299
  timeZone(): string;
299
300
  }
300
301
 
302
+ /**
303
+ * Collections under pages (Decision 7): each held collection's base, worked out again from a
304
+ * fresh listing (held.ts), so a holder that moved takes its collection's addresses with it in
305
+ * every dialog and table without a reload. The payload's own objects are updated in place.
306
+ */
307
+ export function followHolders(site: Pick<EditorSite, 'collections' | 'allCollections'>, listed: readonly ListedDocument[]): void {
308
+ if (!site.collections.some((c) => c.heldBy)) return;
309
+ const bases = heldBases(Object.fromEntries(site.collections.map((c) => [c.name, c])), listed);
310
+ for (const b of Object.values(bases)) {
311
+ for (const c of [...site.collections, ...(site.allCollections ?? [])]) {
312
+ if (c.name !== b.collection) continue;
313
+ c.urlBase = b.base;
314
+ c.holder = b.holder ? { collection: b.holder.collection, path: b.holder.path } : null;
315
+ c.problem = b.problem;
316
+ }
317
+ }
318
+ }
319
+
301
320
  export function createEditor(props: AdminProps, options: { client: boolean }): Editor {
302
321
  const { site } = props;
303
322
  const opened = props.opened;
@@ -743,7 +762,9 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
743
762
  async refreshDocuments() {
744
763
  const response = await fetch(ENDPOINTS.documents, { headers: { Accept: 'application/json' } });
745
764
  if (!response.ok) return;
746
- documents.set((await response.json()) as ListedDocument[]);
765
+ const listed = (await response.json()) as ListedDocument[];
766
+ followHolders(site, listed);
767
+ documents.set(listed);
747
768
  // M6 Decision 9: the unread count rides along (absent when it couldn't be read).
748
769
  const unread = response.headers.get(UNREAD_HEADER);
749
770
  if (unread !== null && /^\d+$/.test(unread)) submissions.set({ unread: Number(unread) });