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.
- package/README.md +59 -3
- package/bin/caracms.mjs +30 -6
- package/bin/gate.mjs +157 -6
- 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/redirect-middleware.ts +3 -0
- package/src/redirects.ts +32 -1
- package/src/relink.ts +40 -9
- package/src/serialize.ts +1 -1
- package/src/slug.ts +15 -4
|
@@ -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,
|
|
@@ -46,6 +46,7 @@ import { link, lstat, mkdir, realpath, rm, rmdir, stat, unlink, writeFile } from
|
|
|
46
46
|
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
47
47
|
import type { ResolvedCollection, ResolvedSite } from '../../config.ts';
|
|
48
48
|
import { DocumentError, documentUrl, resolveDocumentFile, type DocumentEntry } from '../../content.ts';
|
|
49
|
+
import { heldBases, holdsMessage, withHeldBases } from '../../held.ts';
|
|
49
50
|
import { own, parseAnyDocument, type AnyDocument } from '../../document.ts';
|
|
50
51
|
import type { Person } from '../../live/messages.ts';
|
|
51
52
|
import { applyDocumentEdits } from '../../serialize.ts';
|
|
@@ -197,10 +198,15 @@ function firstText(site: Site, collection: ResolvedCollection, title: string, bl
|
|
|
197
198
|
* Check a create against the rules and the store's listing (`documents`: every
|
|
198
199
|
* document in every collection now), and work out the new file. Throws WriteRefused.
|
|
199
200
|
*/
|
|
200
|
-
export function planCreate(
|
|
201
|
+
export function planCreate(configured: Site, request: CreateRequest, documents: readonly DocumentEntry[]): CreatePlan {
|
|
202
|
+
// Collections under pages (Decision 7): a held collection's base is resolved from the listing.
|
|
203
|
+
const bases = heldBases(configured.collections, documents);
|
|
204
|
+
const site = withHeldBases(configured, bases);
|
|
201
205
|
const collection = own(site.collections, request.collection);
|
|
202
206
|
if (!collection) throw refused(400, 'bad-path', `No collection "${printable(request.collection)}" is declared.`);
|
|
203
207
|
if (collection.kind === 'settings') throw refused(400, 'not-allowed', 'Site Settings is always there, so it\'s never created.');
|
|
208
|
+
const unheld = own(bases, collection.name)?.problem;
|
|
209
|
+
if (unheld) throw refused(400, 'not-held', unheld);
|
|
204
210
|
if (request.path === '') throw refused(400, 'bad-name', NO_ADDRESS_MESSAGE);
|
|
205
211
|
if (request.path.split('/').includes(RESERVED_SLUG)) throw refused(400, 'index', INDEX_MESSAGE);
|
|
206
212
|
|
|
@@ -227,7 +233,7 @@ export function planCreate(site: Site, request: CreateRequest, documents: readon
|
|
|
227
233
|
throw refused(409, 'exists', url ? `There's already a page at ${url}.` : `There's already a ${collection.kind === 'shared' ? 'shared block' : 'document'} called "${where.path}".`);
|
|
228
234
|
}
|
|
229
235
|
if (url !== null) {
|
|
230
|
-
const collections = Object.values(site.collections).map(({ name, label, urlBase }) => ({ name, label, urlBase }));
|
|
236
|
+
const collections = Object.values(site.collections).map(({ name, label, urlBase, heldBy }) => ({ name, label, urlBase, heldBy }));
|
|
231
237
|
const problem = addressProblem(url, collection.name, collections, documents, site.codeRoutes);
|
|
232
238
|
if (problem) throw refused(problem.code === 'address-taken' ? 409 : 400, problem.code, problem.message);
|
|
233
239
|
}
|
|
@@ -252,6 +258,11 @@ export function planDelete(site: Site, request: DeleteRequest, documents: readon
|
|
|
252
258
|
throw refused(400, 'not-allowed', collection.urlBase === '/' ? 'Home can\'t be deleted.' : `${entry?.label ?? 'This page'} is the top of ${collection.label}, so it can't be deleted.`);
|
|
253
259
|
}
|
|
254
260
|
if (!entry) throw refused(404, 'not-found', `${collection.label}: no document "${printable(where.path)}".`);
|
|
261
|
+
// Collections under pages (Decision 12): a page that holds a collection stays, as Home does.
|
|
262
|
+
const held = entry.holds ? Object.values(site.collections).find((c) => c.name === entry.holds && c.heldBy === collection.name) : undefined;
|
|
263
|
+
if (held) {
|
|
264
|
+
throw refused(409, 'holds', holdsMessage(entry.label, held.label));
|
|
265
|
+
}
|
|
255
266
|
if (collection.kind === 'shared' && entry.usedOn?.length) {
|
|
256
267
|
const usedOn = entry.usedOn.map((u) => ({ ...u, title: documents.find((d) => d.collection === u.collection && d.path === u.path)?.label ?? u.path }));
|
|
257
268
|
throw refused(
|
|
@@ -12,7 +12,7 @@ import { SITE_SETTINGS_FILE, type ResolvedSite } from '../../config.ts';
|
|
|
12
12
|
import { diskSource, listDocuments, readDocument, type ContentSource } from '../../content.ts';
|
|
13
13
|
import type { AnyOpenedDocument, CreateRequest, CreateResponse, DeleteRequest, DeleteResponse, ListedDocument, MoveRequest, MoveResponse, OpenedDocument, ReplaceFileRequest, ReplaceFileResponse } from '../types.ts';
|
|
14
14
|
import { confineOnDisk, createOnDisk, deleteOnDisk, planCreate, planDelete, target } from './create.ts';
|
|
15
|
-
import {
|
|
15
|
+
import { movesChanges, movesOnDisk, moveResponse, movesUnder, planMoves, settingsWithoutRedirectFrom, type ManyMoveRequest } from './move.ts';
|
|
16
16
|
import { replaceChanges, replaceResponse } from './replace.ts';
|
|
17
17
|
import { applyWrite, fileVersion, writeAtomic, type WriteContext, type WriteRequest, type WriteResponse } from './write.ts';
|
|
18
18
|
|
|
@@ -53,11 +53,19 @@ export interface DraftsStore {
|
|
|
53
53
|
/**
|
|
54
54
|
* Change a document's address (pre-M7 item 8; move.ts has the rules, the same in both
|
|
55
55
|
* stores): the document moves, every link and reference to it is rewritten, and Site
|
|
56
|
-
* Settings gets a redirect from the old address.
|
|
57
|
-
*
|
|
58
|
-
* out at the next Publish. Throws WriteRefused.
|
|
56
|
+
* Settings gets a redirect from the old address. Collections under pages (Decision 9):
|
|
57
|
+
* everything under a page moves with it, through `moveMany`. Throws WriteRefused.
|
|
59
58
|
*/
|
|
60
59
|
move(request: MoveRequest, context?: WriteContext): Promise<MoveResponse>;
|
|
60
|
+
/**
|
|
61
|
+
* The many-move engine (collections under pages, Contracts): every document in the
|
|
62
|
+
* request at its new path, and each held document whose holder's address changes,
|
|
63
|
+
* checked as a whole, then applied all or nothing, each linking file rewritten once and
|
|
64
|
+
* Site Settings written once and last, with every redirect and the caller's own edit.
|
|
65
|
+
* Local: on disk, put back on a failure. Live: every lock taken up front, every change an
|
|
66
|
+
* autosave, all rolled back on a failure, all going out at the next Publish. Throws WriteRefused.
|
|
67
|
+
*/
|
|
68
|
+
moveMany(request: ManyMoveRequest, context?: WriteContext): Promise<MoveResponse>;
|
|
61
69
|
/**
|
|
62
70
|
* M6c G4 (Decision 9): Replace… on a file. Every link to `from`, in every document, points
|
|
63
71
|
* at `to` (replace.ts has the rules, the same in both stores). Local: each file written
|
|
@@ -71,6 +79,15 @@ type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
|
|
|
71
79
|
|
|
72
80
|
/** The local store: the content files on disk. */
|
|
73
81
|
export function localDraftsStore(site: Site): DraftsStore {
|
|
82
|
+
const moveMany = async (request: ManyMoveRequest): Promise<MoveResponse> => {
|
|
83
|
+
for (const m of request.moves) await confineOnDisk(site, target(site, m.collection, m.path).file);
|
|
84
|
+
const documents = await listDocuments(site);
|
|
85
|
+
const plans = planMoves(site, request.moves, documents);
|
|
86
|
+
const read = (file: string) => diskSource.readText(join(site.root, file));
|
|
87
|
+
const changes = await movesChanges(site, plans, documents, read, () => request.redirect !== false, request.editSettings);
|
|
88
|
+
await movesOnDisk(site, changes);
|
|
89
|
+
return moveResponse(plans, changes);
|
|
90
|
+
};
|
|
74
91
|
return {
|
|
75
92
|
async list() {
|
|
76
93
|
const entries = await listDocuments(site);
|
|
@@ -108,12 +125,10 @@ export function localDraftsStore(site: Site): DraftsStore {
|
|
|
108
125
|
async move(request) {
|
|
109
126
|
// The file first (a link out of the collection is refused before anything else), then the rules.
|
|
110
127
|
await confineOnDisk(site, target(site, request.collection, request.path).file);
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
const changes = await moveChanges(site, plan, documents, (file) => diskSource.readText(join(site.root, file)), true);
|
|
114
|
-
await moveOnDisk(site, plan, changes);
|
|
115
|
-
return moveResponse(site, plan, changes);
|
|
128
|
+
const moves = movesUnder(site, request, await listDocuments(site));
|
|
129
|
+
return moveMany({ moves, ...(request.redirect === false ? { redirect: false } : {}) });
|
|
116
130
|
},
|
|
131
|
+
moveMany,
|
|
117
132
|
async replaceFile(request) {
|
|
118
133
|
const changes = await replaceChanges(site, request, await listDocuments(site), (file) => diskSource.readText(join(site.root, file)));
|
|
119
134
|
// As a local move writes its relinked files: each confined and atomic, in listing order.
|