caracms 0.3.4 → 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.
- package/README.md +59 -3
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +51 -10
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +5 -2
- package/src/content.ts +148 -8
- package/src/document.ts +8 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +61 -16
- package/src/editor/client/documents/dialogs.tsx +104 -15
- package/src/editor/client/editor.ts +22 -1
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +19 -1
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +24 -9
- package/src/editor/server/move.ts +266 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +31 -5
- package/src/held.ts +116 -0
- package/src/index.ts +4 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +122 -87
- package/src/live/editor.ts +22 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/redirects.ts +32 -1
- package/src/relink.ts +40 -9
- package/src/serialize.ts +1 -1
- package/src/slug.ts +15 -4
|
@@ -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
|
-
|
|
77
|
-
const
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
flushed =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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.
|
|
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
|
-
|
|
315
|
-
|
|
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
|
|
327
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
412
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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) });
|
|
@@ -7,11 +7,16 @@
|
|
|
7
7
|
* which can't be hidden.
|
|
8
8
|
* - A page under a hidden page is hidden with it (content.ts → `hiddenBy`): the switch shows
|
|
9
9
|
* on and can't be turned off here, and its help names the page to show again.
|
|
10
|
+
* - Collections under pages (Decision 5): a page that holds a collection says so beside it,
|
|
11
|
+
* "Holds Journal (24)" (every document, hidden ones included). Nothing here changes it:
|
|
12
|
+
* the `holds` line is the file's, a developer's call.
|
|
10
13
|
*/
|
|
11
14
|
import { useMemo } from 'react';
|
|
12
15
|
import { useEditor, useStore } from '../context.tsx';
|
|
13
16
|
import { FieldFrame } from './FieldFrame.tsx';
|
|
14
17
|
import { FieldHostProvider, type FieldHost } from './host.tsx';
|
|
18
|
+
import { docIcon } from '../nav/DocRow.tsx';
|
|
19
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
15
20
|
|
|
16
21
|
const SCOPE = 'page-hidden';
|
|
17
22
|
const NAME = 'hidden';
|
|
@@ -30,11 +35,14 @@ export function HiddenToggle() {
|
|
|
30
35
|
const collection = editor.site.collections.find((c) => c.name === ref.collection);
|
|
31
36
|
if (kind !== 'sections' || !collection?.urlBase || ref.path === 'index') return null;
|
|
32
37
|
const listed = documents.find((d) => d.collection === ref.collection && d.path === ref.path);
|
|
33
|
-
|
|
38
|
+
// Collections under pages (Decision 11): a held document is hidden with the page that holds its collection.
|
|
39
|
+
const byCollection = listed?.hiddenByCollection ?? ref.collection;
|
|
40
|
+
const by = listed?.hiddenBy ? documents.find((d) => d.collection === byCollection && d.path === listed.hiddenBy) : undefined;
|
|
34
41
|
const on = hidden || !!by;
|
|
42
|
+
const holds = listed?.holds ? editor.site.collections.find((c) => c.name === listed.holds && c.heldBy === ref.collection) : undefined;
|
|
35
43
|
const id = `f-${SCOPE}-${NAME}`;
|
|
36
44
|
const help = by
|
|
37
|
-
? `Hidden with ${by.label}, the page it's under. Show that page again to show this one.`
|
|
45
|
+
? `Hidden with ${by.label}, the page ${listed?.hiddenByCollection ? `that holds ${collection.label}` : "it's under"}. Show that page again to show this one.`
|
|
38
46
|
: 'Visitors get the 404 page, and it’s left out of the sitemap and the link picker. You can still edit, preview and publish it.';
|
|
39
47
|
return (
|
|
40
48
|
<div className="page-hidden" data-page-hidden={on ? 'on' : 'off'}>
|
|
@@ -46,6 +54,14 @@ export function HiddenToggle() {
|
|
|
46
54
|
</button>
|
|
47
55
|
</FieldFrame>
|
|
48
56
|
</FieldHostProvider>
|
|
57
|
+
{holds ? (
|
|
58
|
+
<p className="page-holds" data-holds={holds.name}>
|
|
59
|
+
<Icon name={docIcon(holds)} size="xs" />
|
|
60
|
+
<span>
|
|
61
|
+
Holds {holds.label} ({documents.filter((d) => d.collection === holds.name).length})
|
|
62
|
+
</span>
|
|
63
|
+
</p>
|
|
64
|
+
) : null}
|
|
49
65
|
</div>
|
|
50
66
|
);
|
|
51
67
|
}
|
|
@@ -26,6 +26,13 @@ export function docIcon(collection: { nested: boolean; kind?: string } | undefin
|
|
|
26
26
|
return 'journal';
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** A hidden document's mark's words: "Journal is hidden" for one hidden with the page that holds its collection (or the page it's under). */
|
|
30
|
+
export function hiddenTitle(doc: ListedDocument, documents: readonly ListedDocument[]): string {
|
|
31
|
+
if (!doc.hiddenBy) return 'Hidden from the site';
|
|
32
|
+
const by = documents.find((d) => d.collection === (doc.hiddenByCollection ?? doc.collection) && d.path === doc.hiddenBy);
|
|
33
|
+
return by ? `${by.label} is hidden` : 'Hidden with the page it’s under';
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
export function DocRow(props: {
|
|
30
37
|
doc: ListedDocument;
|
|
31
38
|
crumb?: string;
|
|
@@ -84,7 +91,7 @@ export function DocRow(props: {
|
|
|
84
91
|
) : null}
|
|
85
92
|
{/* M9 Decision 7: hidden from the site (or under a hidden page). */}
|
|
86
93
|
{doc.hidden ? (
|
|
87
|
-
<span className="hmark" data-mark="hidden" title={doc.
|
|
94
|
+
<span className="hmark" data-mark="hidden" title={hiddenTitle(doc, editor.documents.get())}>
|
|
88
95
|
Hidden
|
|
89
96
|
</span>
|
|
90
97
|
) : null}
|
|
@@ -29,6 +29,22 @@ export type TreeRow =
|
|
|
29
29
|
/** Documents under it (any depth), for a bubbled mark when it's closed. */
|
|
30
30
|
under: ListedDocument[];
|
|
31
31
|
}
|
|
32
|
+
| {
|
|
33
|
+
/**
|
|
34
|
+
* Collections under pages (Decision 4): a collection the page above holds, as one row
|
|
35
|
+
* (its name and count, opening its table), the first row under its holder whenever the
|
|
36
|
+
* holder's row shows. Its documents never go in the tree.
|
|
37
|
+
*/
|
|
38
|
+
kind: 'held';
|
|
39
|
+
/** The holder's path (the row's position). */
|
|
40
|
+
path: string;
|
|
41
|
+
depth: number;
|
|
42
|
+
collection: string;
|
|
43
|
+
label: string;
|
|
44
|
+
/** Every document in it, hidden ones included (as its sidebar entry counts them). */
|
|
45
|
+
count: number;
|
|
46
|
+
files: string[];
|
|
47
|
+
}
|
|
32
48
|
| {
|
|
33
49
|
/** A folder with no page of its own: shows or hides its children only. */
|
|
34
50
|
kind: 'group';
|
|
@@ -46,7 +62,13 @@ export type TreeRow =
|
|
|
46
62
|
* closed group (at any depth) isn't returned. `documents` must be one collection's,
|
|
47
63
|
* in the reader's order.
|
|
48
64
|
*/
|
|
49
|
-
export function treeRows(
|
|
65
|
+
export function treeRows(
|
|
66
|
+
collection: string,
|
|
67
|
+
documents: readonly ListedDocument[],
|
|
68
|
+
open: UiState['open'],
|
|
69
|
+
/** The collections pages of this one hold, each with its holder's path and its documents' files. */
|
|
70
|
+
held: readonly { collection: string; label: string; holder: string; files: string[] }[] = [],
|
|
71
|
+
): TreeRow[] {
|
|
50
72
|
const paths = new Set(documents.map((d) => d.path));
|
|
51
73
|
const isOpen = (path: string) => open[groupKey(collection, path)] === true;
|
|
52
74
|
const under = (path: string) => documents.filter((d) => d.path.startsWith(`${path}/`));
|
|
@@ -88,6 +110,9 @@ export function treeRows(collection: string, documents: readonly ListedDocument[
|
|
|
88
110
|
key: groupKey(collection, doc.path),
|
|
89
111
|
under: kids,
|
|
90
112
|
});
|
|
113
|
+
for (const h of held) {
|
|
114
|
+
if (h.holder === doc.path) rows.push({ kind: 'held', path: doc.path, depth: segments.length, collection: h.collection, label: h.label, count: h.files.length, files: h.files });
|
|
115
|
+
}
|
|
91
116
|
}
|
|
92
117
|
return rows;
|
|
93
118
|
}
|
|
@@ -103,7 +103,7 @@ export function DocumentsLevel() {
|
|
|
103
103
|
) : (
|
|
104
104
|
<>
|
|
105
105
|
{nested.map((c) => (
|
|
106
|
-
<PagesSection key={c.name} collection={c} documents={documents.filter((d) => d.collection === c.name)} />
|
|
106
|
+
<PagesSection key={c.name} collection={c} documents={documents.filter((d) => d.collection === c.name)} all={documents} />
|
|
107
107
|
))}
|
|
108
108
|
{flat.length > 0 ? (
|
|
109
109
|
<section className="grp" aria-label="Collections">
|
|
@@ -131,10 +131,14 @@ export function DocumentsLevel() {
|
|
|
131
131
|
);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
function PagesSection({ collection, documents }: { collection: EditorCollection; documents: ListedDocument[] }) {
|
|
134
|
+
function PagesSection({ collection, documents, all }: { collection: EditorCollection; documents: ListedDocument[]; all: ListedDocument[] }) {
|
|
135
135
|
const editor = useEditor();
|
|
136
136
|
const open = useStore(editor.ui, (u) => u.open);
|
|
137
|
-
|
|
137
|
+
// Collections under pages (Decision 4): each collection a page here holds, as a row under it.
|
|
138
|
+
const held = editor.site.collections
|
|
139
|
+
.filter((c) => c.heldBy === collection.name && c.holder?.collection === collection.name)
|
|
140
|
+
.map((c) => ({ collection: c.name, label: c.label, holder: c.holder!.path, files: all.filter((d) => d.collection === c.name).map((d) => d.file) }));
|
|
141
|
+
const rows = treeRows(collection.name, documents, open, held);
|
|
138
142
|
return (
|
|
139
143
|
<section className="grp" aria-label={collection.label} data-collection={collection.name}>
|
|
140
144
|
<div className="grp-h">
|
|
@@ -155,6 +159,22 @@ function PagesSection({ collection, documents }: { collection: EditorCollection;
|
|
|
155
159
|
function TreeNode({ row, collection }: { row: TreeRow; collection: EditorCollection }) {
|
|
156
160
|
const editor = useEditor();
|
|
157
161
|
const depth = { ['--depth' as string]: row.depth };
|
|
162
|
+
if (row.kind === 'held') {
|
|
163
|
+
const held = editor.site.collections.find((c) => c.name === row.collection);
|
|
164
|
+
return (
|
|
165
|
+
<div className="tnode" data-tnode-held={row.collection}>
|
|
166
|
+
<button type="button" className="drow t coll is-held" style={depth} data-held={row.collection} aria-label={`${row.label}, ${row.count}`} title={`${row.label}, held by this page: its documents are at its address`} onClick={() => editor.openCollection(row.collection)}>
|
|
167
|
+
<Icon name={docIcon(held)} />
|
|
168
|
+
<span className="nm">{row.label}</span>
|
|
169
|
+
<span className="meta">
|
|
170
|
+
{editor.mode === 'live' ? <Bubble files={row.files} /> : <span data-slot="bubble" hidden />}
|
|
171
|
+
<span className="cnt">{row.count}</span>
|
|
172
|
+
</span>
|
|
173
|
+
<Icon name="chev-r" size="sm" />
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
158
178
|
if (row.kind === 'group') {
|
|
159
179
|
return (
|
|
160
180
|
<div className="tnode" data-tnode-group={row.path}>
|
|
@@ -344,17 +364,42 @@ function SubmissionsRow() {
|
|
|
344
364
|
function CollectionRow({ collection, documents }: { collection: EditorCollection; documents: ListedDocument[] }) {
|
|
345
365
|
const editor = useEditor();
|
|
346
366
|
const n = documents.length;
|
|
367
|
+
// Collections under pages (Decision 13): a held collection no page holds (or two do) has no addresses; its entry says why.
|
|
368
|
+
const problem = collection.problem ?? null;
|
|
347
369
|
return (
|
|
348
|
-
|
|
349
|
-
<
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
{
|
|
353
|
-
{
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
370
|
+
<>
|
|
371
|
+
<button
|
|
372
|
+
type="button"
|
|
373
|
+
className={problem ? 'drow coll is-warned' : 'drow coll'}
|
|
374
|
+
data-coll={collection.name}
|
|
375
|
+
aria-label={`${collection.label}, ${n}${problem ? ', has no addresses' : ''}`}
|
|
376
|
+
title={problem ?? undefined}
|
|
377
|
+
onClick={() => editor.openCollection(collection.name)}
|
|
378
|
+
>
|
|
379
|
+
<Icon name={docIcon(collection)} />
|
|
380
|
+
<span className="nm">{collection.label}</span>
|
|
381
|
+
<span className="meta">
|
|
382
|
+
{problem ? (
|
|
383
|
+
<span className="wmark" data-warning="not-held">
|
|
384
|
+
<Icon name="warn" size="xs" />
|
|
385
|
+
</span>
|
|
386
|
+
) : null}
|
|
387
|
+
{/* M4: presence and waiting dots inside the collection, bubbled up. */}
|
|
388
|
+
{editor.mode === 'live' ? <Bubble files={documents.map((d) => d.file)} /> : <span data-slot="bubble" hidden />}
|
|
389
|
+
<span className="cnt">{n}</span>
|
|
390
|
+
</span>
|
|
391
|
+
<Icon name="chev-r" size="sm" />
|
|
392
|
+
</button>
|
|
393
|
+
{problem ? (
|
|
394
|
+
<div className="note warn tight doc-why" role="note" data-why={`coll-${collection.name}`}>
|
|
395
|
+
<Icon name="warn" />
|
|
396
|
+
<div>
|
|
397
|
+
<b>{collection.label} has no addresses</b>
|
|
398
|
+
<p>{problem}</p>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
) : null}
|
|
402
|
+
</>
|
|
358
403
|
);
|
|
359
404
|
}
|
|
360
405
|
|
|
@@ -107,12 +107,25 @@ function GitHubAccessNote() {
|
|
|
107
107
|
<Icon name="warn" />
|
|
108
108
|
<div>
|
|
109
109
|
<b>{access.title}</b>
|
|
110
|
-
<p>{access.message}</p>
|
|
110
|
+
<p>{withLink(access.message, access.link ?? null)}</p>
|
|
111
111
|
</div>
|
|
112
112
|
</div>
|
|
113
113
|
);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** The sentence as it came, with `link` as a link where it names it (or after it); the words are never changed. */
|
|
117
|
+
function withLink(message: string, link: string | null) {
|
|
118
|
+
if (!link) return message;
|
|
119
|
+
const a = (key: number) => (
|
|
120
|
+
<a key={key} href={link} target="_blank" rel="noopener noreferrer">
|
|
121
|
+
{link}
|
|
122
|
+
</a>
|
|
123
|
+
);
|
|
124
|
+
const parts = message.split(link);
|
|
125
|
+
if (parts.length === 1) return [message, ' ', a(0)];
|
|
126
|
+
return parts.flatMap((part, i) => (i === 0 ? [part] : [a(i), part]));
|
|
127
|
+
}
|
|
128
|
+
|
|
116
129
|
function KeepNotes() {
|
|
117
130
|
const editor = useEditor();
|
|
118
131
|
const state = useStore(editor.keep, (k) => k.state);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* POST /_caracms/move: `{ collection, path, to }` → MoveResponse, or `{ error, code }`
|
|
2
|
+
* POST /_caracms/move: `{ collection, path, to, redirect? }` → MoveResponse, or `{ error, code }`
|
|
3
3
|
* (pre-M7 item 8; shapes: ../types.ts → MoveRequest). The rules are in ../server/move.ts;
|
|
4
4
|
* the store is the mode's (server/mode.ts), as for create and delete: on disk in local
|
|
5
5
|
* mode, autosaves under this tab's locks in live mode, where the request carries
|
|
@@ -7,6 +7,7 @@ import { createHash } from 'node:crypto';
|
|
|
7
7
|
import { relative } from 'node:path';
|
|
8
8
|
import type { Brand, ResolvedSite } from '../../config.ts';
|
|
9
9
|
import { DocumentError } from '../../content.ts';
|
|
10
|
+
import { heldBases, withHeldBases } from '../../held.ts';
|
|
10
11
|
import { inboxFromAddress, refFromAddress } from '../addresses.ts';
|
|
11
12
|
import { handleSubmissions, SUBMISSIONS_PREFIX, submissionsSetupQuick, type SubmissionResponse, type SubmissionsListResponse } from '../../forms/inbox.ts';
|
|
12
13
|
import { ENDPOINTS, type AdminProps, type DocRef, type EditorSite, type ListedDocument, type LiveProps, type MediaSetup, type EditorForm, type Person, type StatusResponse } from '../types.ts';
|
|
@@ -34,7 +35,24 @@ export function homeOf(site: Pick<ResolvedSite, 'collections'>, documents: reado
|
|
|
34
35
|
*/
|
|
35
36
|
export function editorSite(site: ResolvedSite, documents: readonly ListedDocument[], host: string, brand: Brand): EditorSite {
|
|
36
37
|
// Every kind of collection (M5a Phase 5): the site's own in config order, then `shared` and `settings`.
|
|
37
|
-
|
|
38
|
+
// Collections under pages (Decision 7): a held collection's urlBase is resolved from the listing, so the browser never works one out.
|
|
39
|
+
const bases = heldBases(site.collections, documents);
|
|
40
|
+
const collections = Object.values(withHeldBases(site, bases).collections).map(({ name, label, kind, urlBase, nested, fields, columns, sort, view, blocks, heldBy }) => {
|
|
41
|
+
const held = heldBy ? bases[name] : undefined;
|
|
42
|
+
return {
|
|
43
|
+
name,
|
|
44
|
+
label,
|
|
45
|
+
kind,
|
|
46
|
+
urlBase,
|
|
47
|
+
nested,
|
|
48
|
+
fields,
|
|
49
|
+
columns,
|
|
50
|
+
sort: sort ?? null,
|
|
51
|
+
view: view ?? null,
|
|
52
|
+
blocks: blocks ?? null,
|
|
53
|
+
...(held ? { heldBy, holder: held.holder && !held.problem ? { collection: held.holder.collection, path: held.holder.path } : null, problem: held.problem } : {}),
|
|
54
|
+
};
|
|
55
|
+
});
|
|
38
56
|
return {
|
|
39
57
|
collections,
|
|
40
58
|
allCollections: collections,
|