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
|
@@ -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.
|
|
@@ -18,41 +18,46 @@
|
|
|
18
18
|
* it's free in every collection (the document's own address aside), not under
|
|
19
19
|
* `/admin` or `/_caracms`, not strictly below another collection's `urlBase`. The
|
|
20
20
|
* same address is refused (`same`): there's nothing to change.
|
|
21
|
-
* - **
|
|
22
|
-
*
|
|
21
|
+
* - **Everything under it moves with it** (collections under pages, Decision 9; a delete
|
|
22
|
+
* still leaves them, M5a Decision 12): moving Services to /offerings carries
|
|
23
|
+
* /services/branding to /offerings/branding, and a page that holds a collection carries
|
|
24
|
+
* its documents' addresses (`movesUnder`, `planMoves`). The whole plan is checked, then
|
|
25
|
+
* lands all or nothing: the many-move engine (Contracts), which the arrange screen reuses.
|
|
23
26
|
*
|
|
24
|
-
* What a move changes (`
|
|
27
|
+
* What a move changes (`movesChanges`), worked out from the store's own texts, each file once for every move:
|
|
25
28
|
*
|
|
26
29
|
* - the document, at its new path, with its own links to itself rewritten;
|
|
27
30
|
* - **every other document that links to it or references it** (relink.ts), Site
|
|
28
31
|
* Settings included, each changed only where it points at the old address;
|
|
29
32
|
* - **Site Settings' redirects** (redirects.ts): entries to the old address go to the
|
|
30
33
|
* new one (chains collapse), an entry from the new address goes (a page is there
|
|
31
|
-
* now), and `old → new` is added, unless the
|
|
32
|
-
*
|
|
34
|
+
* now), and `old → new` is added, unless the request says not to (`redirect: false`,
|
|
35
|
+
* Change address…'s box unticked: pages shuffled before a site launches need none)
|
|
36
|
+
* or the document was never live (live mode's document made since the last Publish).
|
|
37
|
+
* Either way `addRedirect` is false; the chains still collapse.
|
|
33
38
|
*
|
|
34
|
-
* On disk the order is: the new
|
|
35
|
-
* Settings, then the old
|
|
36
|
-
* it was, never no document.
|
|
39
|
+
* On disk the order is: the new files (exclusive creates), the rewritten files, Site
|
|
40
|
+
* Settings, then the old files go; a failure part way puts every file back (`movesOnDisk`).
|
|
37
41
|
*/
|
|
38
|
-
import { writeFile } from 'node:fs/promises';
|
|
42
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
39
43
|
import { join } from 'node:path';
|
|
40
44
|
import { SITE_SETTINGS_FILE, type ResolvedCollection, type ResolvedSite } from '../../config.ts';
|
|
41
45
|
import { documentUrl, type DocumentEntry } from '../../content.ts';
|
|
46
|
+
import { heldBases, withHeldBases, type HeldBase } from '../../held.ts';
|
|
42
47
|
import { own, parseAnyDocument, type SettingsDocument } from '../../document.ts';
|
|
43
48
|
import type { Person } from '../../live/messages.ts';
|
|
44
|
-
import {
|
|
49
|
+
import { redirectsAddedBy, redirectsAfterMoves, type Redirect } from '../../redirects.ts';
|
|
45
50
|
import { relinkText, type AddressMove } from '../../relink.ts';
|
|
46
51
|
import { schemaRef, stampSchema } from '../../schema-key.ts';
|
|
47
52
|
import { applyKeyedEdits } from '../../serialize.ts';
|
|
48
|
-
import { addressProblem, INDEX_MESSAGE, isSlug, NO_ADDRESS_MESSAGE, RESERVED_SLUG, slugify } from '../../slug.ts';
|
|
53
|
+
import { addressProblem, INDEX_MESSAGE, isSlug, NO_ADDRESS_MESSAGE, RESERVED_SLUG, sameAddress, slugify } from '../../slug.ts';
|
|
49
54
|
import { RELEASE_HEADER, type MoveRequest, type MoveResponse } from '../types.ts';
|
|
50
55
|
import { confineOnDisk, createOnDisk, deleteOnDisk, MAX_CREATE_BODY_BYTES, refused, target, type Target } from './create.ts';
|
|
51
56
|
import { checkOrigin, seqOf, tabOf, WriteRefused, writeAtomic, type WriteContext } from './write.ts';
|
|
52
57
|
|
|
53
58
|
type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks' | 'codeRoutes'>;
|
|
54
59
|
|
|
55
|
-
const MOVE_SHAPE = '{ collection, path, to }';
|
|
60
|
+
const MOVE_SHAPE = '{ collection, path, to, redirect? }';
|
|
56
61
|
|
|
57
62
|
/** A path for a message: control characters shown escaped, long ones cut. */
|
|
58
63
|
const printable = (value: string) => {
|
|
@@ -64,26 +69,54 @@ const printable = (value: string) => {
|
|
|
64
69
|
export function parseMoveBody(body: unknown): MoveRequest {
|
|
65
70
|
if (!body || typeof body !== 'object' || Array.isArray(body)) throw refused(400, 'bad-request', `Malformed body: it must be a JSON object. Send ${MOVE_SHAPE}.`);
|
|
66
71
|
const b = body as Record<string, unknown>;
|
|
67
|
-
const extra = Object.keys(b).filter((k) => !['collection', 'path', 'to'].includes(k));
|
|
72
|
+
const extra = Object.keys(b).filter((k) => !['collection', 'path', 'to', 'redirect'].includes(k));
|
|
68
73
|
if (extra.length) throw refused(400, 'bad-request', `Malformed body: unexpected ${extra.map((k) => `"${printable(k)}"`).join(', ')}. Send ${MOVE_SHAPE}.`);
|
|
69
74
|
if (typeof b.collection !== 'string' || b.collection === '') throw refused(400, 'bad-request', `Malformed body: "collection" must be a collection's name. Send ${MOVE_SHAPE}.`);
|
|
70
75
|
if (typeof b.path !== 'string' || b.path === '') throw refused(400, 'bad-request', `Malformed body: "path" must be the document's path in its collection. Send ${MOVE_SHAPE}.`);
|
|
71
76
|
if (typeof b.to !== 'string') throw refused(400, 'bad-request', `Malformed body: "to" must be the document's new path in its collection. Send ${MOVE_SHAPE}.`);
|
|
77
|
+
if (b.redirect !== undefined && typeof b.redirect !== 'boolean') throw refused(400, 'bad-request', `Malformed body: "redirect" must be true or false. Send ${MOVE_SHAPE}.`);
|
|
72
78
|
if (b.path.length > 512 || b.to.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
|
|
73
|
-
return { collection: b.collection, path: b.path, to: b.to };
|
|
79
|
+
return { collection: b.collection, path: b.path, to: b.to, ...(b.redirect === false ? { redirect: false } : {}) };
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
/** One document's new path in its collection: the many-move engine's unit (Contracts). */
|
|
83
|
+
export interface PathMove {
|
|
84
|
+
collection: string;
|
|
85
|
+
path: string;
|
|
86
|
+
to: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The many-move engine's request (collections under pages, Decision 9 and Contracts):
|
|
91
|
+
* every document whose path changes, any collections, any number; the one redirect
|
|
92
|
+
* choice; and an optional change to Site Settings' text that lands, or rolls back, with
|
|
93
|
+
* the redirects (the arrange screen's saved order). A held document whose holder moves
|
|
94
|
+
* keeps its path, so it's never listed: the plan adds it.
|
|
95
|
+
*/
|
|
96
|
+
export interface ManyMoveRequest {
|
|
97
|
+
moves: readonly PathMove[];
|
|
98
|
+
/** Whether to leave redirects from the old addresses (default true). A document that was never live gets none either way. */
|
|
99
|
+
redirect?: boolean;
|
|
100
|
+
/** A pure function, Site Settings' text → its text, applied after the redirects are folded in. */
|
|
101
|
+
editSettings?: (text: string) => string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One address that changes. */
|
|
76
105
|
export interface MovePlan {
|
|
77
106
|
from: Target & { entry: DocumentEntry; url: string };
|
|
78
107
|
to: Target & { url: string };
|
|
79
108
|
move: AddressMove;
|
|
80
109
|
title: string;
|
|
110
|
+
/** False for a held document whose holder moved: its file stays where it is, only its address changes. */
|
|
111
|
+
refiled: boolean;
|
|
81
112
|
}
|
|
82
113
|
|
|
83
|
-
/**
|
|
84
|
-
|
|
114
|
+
/** The individual rules for one requested move, against the listing as it is. Throws WriteRefused. */
|
|
115
|
+
function checkOne(site: Site, bases: Readonly<Record<string, HeldBase>>, request: PathMove, documents: readonly DocumentEntry[]) {
|
|
85
116
|
const collection = own(site.collections, request.collection);
|
|
86
117
|
if (!collection) throw refused(400, 'bad-path', `No collection "${printable(request.collection)}" is declared.`);
|
|
118
|
+
const unheld = own(bases, collection.name)?.problem;
|
|
119
|
+
if (unheld) throw refused(400, 'not-held', unheld);
|
|
87
120
|
if (collection.urlBase === null) {
|
|
88
121
|
const what = collection.kind === 'shared' ? 'A shared block' : collection.kind === 'settings' ? 'Site Settings' : `${collection.label} has no pages of its own, so a document there`;
|
|
89
122
|
throw refused(400, 'not-allowed', `${what} has no address to change.`);
|
|
@@ -110,40 +143,142 @@ export function planMove(site: Site, request: MoveRequest, documents: readonly D
|
|
|
110
143
|
const better = slugify(part);
|
|
111
144
|
throw refused(400, 'bad-name', better ? `"${printable(part)}" can't be part of an address; "${better}" can.` : NO_ADDRESS_MESSAGE);
|
|
112
145
|
});
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
146
|
+
return { collection, from: { ...from, entry, url: fromUrl }, to };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Change address… on a page (Decision 9): the page, then every page under it at any
|
|
151
|
+
* depth, each at the same place under the new address. (Held documents follow their
|
|
152
|
+
* holder in `planMoves`.) Anything else is the one move asked for.
|
|
153
|
+
*/
|
|
154
|
+
export function movesUnder(site: Pick<Site, 'collections'>, request: Pick<PathMove, 'collection' | 'path' | 'to'>, documents: readonly DocumentEntry[]): PathMove[] {
|
|
155
|
+
const root: PathMove = { collection: request.collection, path: request.path, to: request.to };
|
|
156
|
+
const collection = own(site.collections, request.collection);
|
|
157
|
+
if (!collection?.nested || request.to === request.path) return [root];
|
|
158
|
+
if (request.to.startsWith(`${request.path}/`)) {
|
|
159
|
+
const label = documents.find((d) => d.collection === collection.name && d.path === request.path)?.label ?? request.path;
|
|
160
|
+
throw refused(400, 'not-allowed', `${label} can't go under itself.`);
|
|
161
|
+
}
|
|
162
|
+
const under = documents.filter((d) => d.collection === collection.name && d.path.startsWith(`${request.path}/`));
|
|
163
|
+
// A file under it that can't be opened has no address to carry: it would be left behind as a group.
|
|
164
|
+
const broken = under.find((d) => d.warning);
|
|
165
|
+
if (broken) {
|
|
166
|
+
const label = documents.find((d) => d.collection === collection.name && d.path === request.path)?.label ?? request.path;
|
|
167
|
+
throw refused(400, 'not-allowed', `${broken.file} is under ${label} and can't be opened, so ${label} can't move with everything under it. Fix or delete that file first.`);
|
|
168
|
+
}
|
|
169
|
+
return [root, ...under.map((d) => ({ collection: d.collection, path: d.path, to: `${request.to}${d.path.slice(request.path.length)}` }))];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check a whole plan against the rules and the store's listing at call time (every
|
|
174
|
+
* document, every collection), and work out every address that changes: the requested
|
|
175
|
+
* moves, in order, then the documents of each held collection whose holder's address
|
|
176
|
+
* changes. Each requested move follows a single move's rules; then, as a whole, every new
|
|
177
|
+
* address must be free of documents (as they'll be once the plan has landed, so a swap is
|
|
178
|
+
* fine) and code routes, none reserved, and no two moves may land on one address.
|
|
179
|
+
* Throws WriteRefused, naming the first problem.
|
|
180
|
+
*/
|
|
181
|
+
export function planMoves(configured: Site, requests: readonly PathMove[], documents: readonly DocumentEntry[]): MovePlan[] {
|
|
182
|
+
if (requests.length === 0) throw refused(400, 'bad-request', 'Nothing to move.');
|
|
183
|
+
// Collections under pages (Decision 7): a held collection's base is resolved from the listing.
|
|
184
|
+
const before = heldBases(configured.collections, documents);
|
|
185
|
+
const site = withHeldBases(configured, before);
|
|
186
|
+
const checked = requests.map((r) => checkOne(site, before, r, documents));
|
|
187
|
+
const moving = new Map<string, (typeof checked)[number]>();
|
|
188
|
+
for (const c of checked) {
|
|
189
|
+
if (moving.has(c.from.file)) throw refused(400, 'bad-request', `${c.from.entry.label} is in the plan twice.`);
|
|
190
|
+
moving.set(c.from.file, c);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The listing once the plan has landed: moved documents at their new paths, then every
|
|
194
|
+
// held collection's base worked out again from it (a holder may have moved).
|
|
195
|
+
const shifted = documents.map((d) => {
|
|
196
|
+
const c = moving.get(d.file);
|
|
197
|
+
return c ? { ...d, path: c.to.path, file: c.to.file, url: documentUrl(site.collections[d.collection]!, c.to.path) } : d;
|
|
198
|
+
});
|
|
199
|
+
const after = heldBases(configured.collections, shifted);
|
|
200
|
+
const landed = withHeldBases(configured, after);
|
|
201
|
+
const urlAfter = (d: { collection: string; path: string }) => documentUrl(landed.collections[d.collection]!, d.path);
|
|
202
|
+
|
|
203
|
+
const plans: MovePlan[] = checked.map((c) => {
|
|
204
|
+
const toUrl = urlAfter(c.to);
|
|
205
|
+
if (toUrl === null) throw refused(400, 'not-held', own(after, c.collection.name)?.problem ?? `${c.from.entry.label} would have no address.`);
|
|
206
|
+
return {
|
|
207
|
+
from: c.from,
|
|
208
|
+
to: { ...c.to, url: toUrl },
|
|
209
|
+
move: { collection: c.collection.name, fromPath: c.from.path, toPath: c.to.path, fromUrl: c.from.url, toUrl },
|
|
210
|
+
title: c.from.entry.label,
|
|
211
|
+
refiled: true,
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
// Each held collection whose holder's address changes: its documents follow, each an ordinary move (Decision 3).
|
|
215
|
+
for (const b of Object.values(before)) {
|
|
216
|
+
const next = after[b.collection];
|
|
217
|
+
if (b.base === null || !next?.base || next.base === b.base) continue;
|
|
218
|
+
for (const d of documents) {
|
|
219
|
+
if (d.collection !== b.collection || d.warning || moving.has(d.file)) continue;
|
|
220
|
+
const fromUrl = documentUrl(site.collections[d.collection]!, d.path);
|
|
221
|
+
const toUrl = urlAfter(d);
|
|
222
|
+
if (fromUrl === null || toUrl === null) continue;
|
|
223
|
+
const at = target(site, d.collection, d.path);
|
|
224
|
+
plans.push({ from: { ...at, entry: d, url: fromUrl }, to: { ...at, url: toUrl }, move: { collection: d.collection, fromPath: d.path, toPath: d.path, fromUrl, toUrl }, title: d.label, refiled: false });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// As a whole: every new address free, none reserved, no two moves to one.
|
|
229
|
+
const lead = (p: MovePlan, i: number) => (i === 0 ? '' : `${p.title} would move to ${p.to.url}. `);
|
|
230
|
+
const listedAfter = shifted.map((d) => ({ ...d, url: urlAfter(d) }));
|
|
231
|
+
const collections = Object.values(landed.collections).map(({ name, label, urlBase, heldBy }) => ({ name, label, urlBase, heldBy }));
|
|
232
|
+
const vacated = new Set(plans.filter((p) => p.refiled).map((p) => p.from.file));
|
|
233
|
+
const seen = new Map<string, MovePlan>();
|
|
234
|
+
plans.forEach((p, i) => {
|
|
235
|
+
const key = sameAddress(p.to.url);
|
|
236
|
+
const twice = seen.get(key);
|
|
237
|
+
if (twice) throw refused(409, 'address-taken', `${twice.title} and ${p.title} would both be at ${p.to.url}.`);
|
|
238
|
+
seen.set(key, p);
|
|
239
|
+
if (p.refiled && !vacated.has(p.to.file) && documents.some((d) => d.file === p.to.file)) throw refused(409, 'address-taken', `${lead(p, i)}There's already a page at ${p.to.url}.`);
|
|
240
|
+
const others = listedAfter.filter((d) => d.file !== p.to.file);
|
|
241
|
+
const problem = addressProblem(p.to.url, p.move.collection, collections, others, site.codeRoutes);
|
|
242
|
+
if (problem) throw refused(problem.code === 'address-taken' ? 409 : 400, problem.code, `${lead(p, i)}${problem.message}`);
|
|
243
|
+
});
|
|
244
|
+
return plans;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** One move's plan (a page alone, with nothing under it). See `planMoves`. */
|
|
248
|
+
export function planMove(site: Site, request: PathMove, documents: readonly DocumentEntry[]): MovePlan {
|
|
249
|
+
return planMoves(site, [request], documents)[0]!;
|
|
125
250
|
}
|
|
126
251
|
|
|
127
252
|
export interface MoveChanges {
|
|
128
|
-
/**
|
|
129
|
-
moved: string;
|
|
130
|
-
/** Every other document whose text changes (links or references to
|
|
253
|
+
/** Each refiled document's text at its new path, in plan order. */
|
|
254
|
+
moved: { plan: MovePlan; text: string }[];
|
|
255
|
+
/** Every other document whose text changes (links or references to any of them), Site Settings aside: a held document whose file stays among them. */
|
|
131
256
|
relinked: { file: string; collection: string; path: string; title: string; text: string }[];
|
|
132
257
|
/** Site Settings' text after (null: no change), and before (null: the file isn't there yet). */
|
|
133
258
|
settings: { before: string | null; after: string } | null;
|
|
134
|
-
|
|
259
|
+
/** The redirects added, one per address that was live (and asked for). */
|
|
260
|
+
redirects: Redirect[];
|
|
261
|
+
/** Whether Site Settings changed for its own links, not only its redirects. */
|
|
262
|
+
settingsRelinked: boolean;
|
|
135
263
|
}
|
|
136
264
|
|
|
137
265
|
const settingsCollection = (site: Site): ResolvedCollection | undefined => Object.values(site.collections).find((c) => c.kind === 'settings');
|
|
138
266
|
|
|
139
267
|
/**
|
|
140
|
-
* Site Settings' text once its redirects follow
|
|
141
|
-
*
|
|
268
|
+
* Site Settings' text once its redirects follow every move (redirects.ts →
|
|
269
|
+
* `redirectsAfterMoves`: all at once, against the list before the plan, so a swap or a
|
|
270
|
+
* chain comes out right; `add` per move), its own links are rewritten, and the caller's own
|
|
271
|
+
* `edit` is applied; null when nothing changes. `text` null: there's no Site Settings file yet.
|
|
142
272
|
*/
|
|
143
|
-
export function
|
|
273
|
+
export function settingsAfterMoves(
|
|
274
|
+
site: Site,
|
|
275
|
+
text: string | null,
|
|
276
|
+
moves: readonly { move: AddressMove; add: boolean }[],
|
|
277
|
+
edit?: (text: string) => string,
|
|
278
|
+
): string | null {
|
|
144
279
|
const collection = settingsCollection(site);
|
|
145
280
|
if (!collection) return null;
|
|
146
|
-
const relinked = text === null ? null : relinkText(text, move, site, collection).text;
|
|
281
|
+
const relinked = text === null ? null : relinkText(text, moves.map((m) => m.move), site, collection).text;
|
|
147
282
|
let raw: unknown = {};
|
|
148
283
|
if (relinked !== null) {
|
|
149
284
|
try {
|
|
@@ -153,12 +288,18 @@ export function settingsAfterMove(site: Site, text: string | null, move: Address
|
|
|
153
288
|
}
|
|
154
289
|
}
|
|
155
290
|
const doc = parseAnyDocument(raw, site, collection) as SettingsDocument;
|
|
156
|
-
const redirects =
|
|
291
|
+
const redirects = redirectsAfterMoves(doc.data.redirects ?? [], moves.map((m) => ({ from: m.move.fromUrl, to: m.move.toUrl, add: m.add })));
|
|
157
292
|
const title = relinked === null ? 'Site Settings' : doc.title;
|
|
158
|
-
|
|
293
|
+
let after = applyKeyedEdits(relinked, { title, data: { ...doc.data, redirects } }, collection);
|
|
294
|
+
if (edit) after = edit(after);
|
|
159
295
|
return after === text ? null : after;
|
|
160
296
|
}
|
|
161
297
|
|
|
298
|
+
/** One move's Site Settings (see `settingsAfterMoves`). */
|
|
299
|
+
export function settingsAfterMove(site: Site, text: string | null, move: AddressMove, addRedirect: boolean): string | null {
|
|
300
|
+
return settingsAfterMoves(site, text, [{ move, add: addRedirect }]);
|
|
301
|
+
}
|
|
302
|
+
|
|
162
303
|
/**
|
|
163
304
|
* Site Settings' text without its redirect from `url`, or null when it has none: a new
|
|
164
305
|
* document made at an address that redirected (a page is there again). Both stores'
|
|
@@ -180,79 +321,123 @@ export function settingsWithoutRedirectFrom(site: Site, text: string | null, url
|
|
|
180
321
|
}
|
|
181
322
|
|
|
182
323
|
/**
|
|
183
|
-
* What the
|
|
184
|
-
* live mode's has the autosaves on top)
|
|
324
|
+
* What the plan changes, from the store's own texts (`readText` by site-relative file;
|
|
325
|
+
* live mode's has the autosaves on top): each linking file rewritten once for all the
|
|
326
|
+
* moves that touch it. `addRedirect(plan)`: whether that address gets `old → new`.
|
|
185
327
|
*/
|
|
186
|
-
export async function
|
|
328
|
+
export async function movesChanges(
|
|
187
329
|
site: Site,
|
|
188
|
-
|
|
330
|
+
plans: readonly MovePlan[],
|
|
189
331
|
documents: readonly DocumentEntry[],
|
|
190
332
|
readText: (file: string) => Promise<string | null>,
|
|
191
|
-
addRedirect: boolean,
|
|
333
|
+
addRedirect: (plan: MovePlan) => boolean,
|
|
334
|
+
editSettings?: (text: string) => string,
|
|
192
335
|
): Promise<MoveChanges> {
|
|
193
|
-
const
|
|
194
|
-
if (text === null) throw refused(404, 'not-found', `${site.collections[plan.from.collection]!.label}: no document "${plan.from.path}".`);
|
|
336
|
+
const moves = plans.map((p) => p.move);
|
|
195
337
|
const collectionOf = (name: string) => site.collections[name]!;
|
|
196
|
-
|
|
197
|
-
const moved =
|
|
338
|
+
const refiled = new Set(plans.filter((p) => p.refiled).map((p) => p.from.file));
|
|
339
|
+
const moved: MoveChanges['moved'] = [];
|
|
340
|
+
for (const plan of plans) {
|
|
341
|
+
if (!plan.refiled) continue;
|
|
342
|
+
const text = await readText(plan.from.file);
|
|
343
|
+
if (text === null) throw refused(404, 'not-found', `${collectionOf(plan.from.collection).label}: no document "${plan.from.path}".`);
|
|
344
|
+
// M8 Decision 14: its `$schema` path follows the file to its new folder.
|
|
345
|
+
moved.push({ plan, text: stampSchema(relinkText(text, moves, site, collectionOf(plan.from.collection)).text, schemaRef(collectionOf(plan.to.collection), plan.to.file), { add: false }) });
|
|
346
|
+
}
|
|
198
347
|
const relinked: MoveChanges['relinked'] = [];
|
|
199
348
|
for (const d of documents) {
|
|
200
|
-
if (d.file
|
|
349
|
+
if (refiled.has(d.file) || d.file === SITE_SETTINGS_FILE) continue;
|
|
201
350
|
const text = await readText(d.file);
|
|
202
351
|
if (text === null) continue;
|
|
203
|
-
const after = relinkText(text,
|
|
352
|
+
const after = relinkText(text, moves, site, collectionOf(d.collection));
|
|
204
353
|
if (after.links) relinked.push({ file: d.file, collection: d.collection, path: d.path, title: d.label, text: after.text });
|
|
205
354
|
}
|
|
206
355
|
const before = await readText(SITE_SETTINGS_FILE);
|
|
207
|
-
const
|
|
356
|
+
const adds = plans.map((p) => ({ move: p.move, add: addRedirect(p) }));
|
|
357
|
+
const after = settingsAfterMoves(site, before, adds, editSettings);
|
|
358
|
+
const settings = settingsCollection(site);
|
|
208
359
|
return {
|
|
209
360
|
moved,
|
|
210
361
|
relinked,
|
|
211
362
|
settings: after === null ? null : { before, after },
|
|
212
|
-
|
|
363
|
+
redirects: redirectsAddedBy(adds.map((a) => ({ from: a.move.fromUrl, to: a.move.toUrl, add: a.add }))),
|
|
364
|
+
settingsRelinked: !!settings && before !== null && relinkText(before, moves, site, settings).links > 0,
|
|
213
365
|
};
|
|
214
366
|
}
|
|
215
367
|
|
|
216
|
-
/** The route's answer for a
|
|
217
|
-
export function moveResponse(
|
|
368
|
+
/** The route's answer for a plan done: the first plan's document is the one asked about. */
|
|
369
|
+
export function moveResponse(plans: readonly MovePlan[], changes: MoveChanges): MoveResponse {
|
|
370
|
+
const [first] = plans;
|
|
218
371
|
const relinked = changes.relinked.map(({ collection, path, title }) => ({ collection, path, title }));
|
|
219
|
-
if (changes.
|
|
372
|
+
if (changes.settingsRelinked) relinked.push({ collection: 'settings', path: 'site-settings', title: 'Site Settings' });
|
|
373
|
+
const redirectOf = (p: MovePlan) => changes.redirects.find((r) => r.from === p.move.fromUrl) ?? null;
|
|
220
374
|
return {
|
|
221
375
|
ok: true,
|
|
222
|
-
collection:
|
|
223
|
-
path:
|
|
224
|
-
file:
|
|
225
|
-
url:
|
|
226
|
-
from: { path:
|
|
376
|
+
collection: first!.to.collection,
|
|
377
|
+
path: first!.to.path,
|
|
378
|
+
file: first!.to.file,
|
|
379
|
+
url: first!.to.url,
|
|
380
|
+
from: { path: first!.from.path, file: first!.from.file, url: first!.from.url },
|
|
227
381
|
relinked,
|
|
228
|
-
redirect:
|
|
382
|
+
redirect: redirectOf(first!),
|
|
383
|
+
moved: plans.map((p) => ({ collection: p.move.collection, from: p.move.fromPath, to: p.move.toPath, title: p.title, fromUrl: p.move.fromUrl, toUrl: p.move.toUrl })),
|
|
384
|
+
redirects: changes.redirects,
|
|
229
385
|
};
|
|
230
386
|
}
|
|
231
387
|
|
|
232
|
-
/** Whether Site Settings changed for its own links (not only its redirects). */
|
|
233
|
-
function relinkTouched(site: Site, settings: { before: string | null }, move: AddressMove): boolean {
|
|
234
|
-
const collection = settingsCollection(site);
|
|
235
|
-
return !!collection && settings.before !== null && relinkText(settings.before, move, site, collection).links > 0;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
388
|
// ── The disk (local mode) ──────────────────────────────────────────────────
|
|
239
389
|
|
|
240
|
-
/**
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
390
|
+
/**
|
|
391
|
+
* The plan on disk, all or nothing: each new file (an exclusive create; a file another
|
|
392
|
+
* move in the plan vacates is written over, as a swap needs), the rewritten files, Site
|
|
393
|
+
* Settings, then the old files go (and the folders they leave empty). Every text was read
|
|
394
|
+
* first, so a failure part way puts every file back as it was.
|
|
395
|
+
*/
|
|
396
|
+
export async function movesOnDisk(site: Site, changes: MoveChanges): Promise<void> {
|
|
397
|
+
const vacated = new Set(changes.moved.map((m) => m.plan.from.file));
|
|
398
|
+
const targets = new Set(changes.moved.map((m) => m.plan.to.file));
|
|
399
|
+
const undo: (() => Promise<unknown>)[] = [];
|
|
400
|
+
const overwrite = async (file: string, text: string, before: string | null) => {
|
|
401
|
+
const confined = await confineOnDisk(site, file);
|
|
402
|
+
const prior = before ?? (await readFile(confined.absolute, 'utf8'));
|
|
403
|
+
await writeAtomic(confined.absolute, text);
|
|
404
|
+
undo.push(() => writeAtomic(confined.absolute, prior));
|
|
405
|
+
};
|
|
406
|
+
try {
|
|
407
|
+
for (const { plan, text } of changes.moved) {
|
|
408
|
+
if (vacated.has(plan.to.file)) {
|
|
409
|
+
await overwrite(plan.to.file, text, null);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const collection = site.collections[plan.to.collection]!;
|
|
413
|
+
const document = parseAnyDocument(JSON.parse(text), site, collection);
|
|
414
|
+
await createOnDisk(site, { ...plan.to, title: plan.title, text, document });
|
|
415
|
+
undo.push(() => deleteOnDisk(site, { ...plan.to, entry: plan.from.entry }));
|
|
416
|
+
}
|
|
417
|
+
for (const r of changes.relinked) await overwrite(r.file, r.text, null);
|
|
418
|
+
if (changes.settings) {
|
|
419
|
+
const file = join(site.root, SITE_SETTINGS_FILE);
|
|
420
|
+
if (changes.settings.before === null) {
|
|
421
|
+
await writeFile(file, changes.settings.after, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
|
|
422
|
+
undo.push(() => rm(file, { force: true }));
|
|
423
|
+
} else {
|
|
424
|
+
await writeAtomic(file, changes.settings.after);
|
|
425
|
+
const before = changes.settings.before;
|
|
426
|
+
undo.push(() => writeAtomic(file, before));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
for (const { plan } of changes.moved) {
|
|
430
|
+
if (targets.has(plan.from.file)) continue;
|
|
431
|
+
const confined = await confineOnDisk(site, plan.from.file);
|
|
432
|
+
const text = await readFile(confined.absolute, 'utf8');
|
|
433
|
+
await deleteOnDisk(site, { ...plan.from, entry: plan.from.entry });
|
|
434
|
+
const collection = site.collections[plan.from.collection]!;
|
|
435
|
+
undo.push(() => createOnDisk(site, { ...plan.from, title: plan.title, text, document: parseAnyDocument(JSON.parse(text), site, collection) }));
|
|
436
|
+
}
|
|
437
|
+
} catch (error) {
|
|
438
|
+
for (const step of undo.reverse()) await step().catch(() => {});
|
|
439
|
+
throw error;
|
|
254
440
|
}
|
|
255
|
-
await deleteOnDisk(site, { ...plan.from, entry: plan.from.entry });
|
|
256
441
|
}
|
|
257
442
|
|
|
258
443
|
// ── The route ──────────────────────────────────────────────────────────────
|
|
@@ -228,6 +228,8 @@ details.fgroup>summary .wmark{margin-left:var(--space-1)}
|
|
|
228
228
|
details.fgroup>summary .wmark .i{margin-left:0;rotate:none}
|
|
229
229
|
/* M9 Decision 7: the page's "Hidden from the site" switch, between its page fields and SEO. */
|
|
230
230
|
.page-hidden{margin-top:var(--space-4)}
|
|
231
|
+
/* Collections under pages (Decision 5): "Holds Journal (24)", beside Hidden, with no control */
|
|
232
|
+
.page-holds{display:flex;align-items:center;gap:var(--space-2);margin:var(--space-2) 0 0;font-size:var(--text-sm);color:var(--text-muted)}
|
|
231
233
|
/* A group field (group()): Page details' section, among a form's fields; inside a list item it keeps to the item's width. */
|
|
232
234
|
details.fgroup.group-field{margin-block:var(--space-3)}
|
|
233
235
|
details.fgroup.group-field+details.fgroup.group-field{margin-top:calc(-1 * var(--space-3))}
|
|
@@ -462,6 +464,9 @@ details.fgroup.group-field+details.fgroup.group-field{margin-top:calc(-1 * var(-
|
|
|
462
464
|
.dialog>p,.dialog>form>p{color:var(--text-muted)}
|
|
463
465
|
.dialog>p+p,.dialog>form>p+p{margin-top:var(--space-2)}
|
|
464
466
|
.dialog .field{margin:var(--space-4) 0 0}
|
|
467
|
+
.dialog .check{display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text);cursor:pointer}
|
|
468
|
+
.dialog .check input{flex:none;width:16px;height:16px;margin:0;accent-color:var(--select);cursor:inherit}
|
|
469
|
+
.dialog .check + .field-help{margin-top:var(--space-1);padding-left:calc(16px + var(--space-2))}
|
|
465
470
|
.dialog .acts{display:flex;justify-content:flex-end;gap:var(--space-2);margin-top:var(--space-6)}
|
|
466
471
|
.dialog .icon-h{display:grid;place-items:center;width:36px;height:36px;margin-bottom:var(--space-3);border-radius:50%}
|
|
467
472
|
.urlpre{display:flex;align-items:center;gap:var(--space-1);margin-top:var(--space-1-5);font-size:var(--text-sm);color:var(--text-muted)}
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
.ct-open:focus-visible{outline:2px solid var(--select);outline-offset:2px;border-radius:var(--radius-xs)}
|
|
38
38
|
.ct-title-cell .meta{display:inline-flex;align-items:center;gap:var(--space-1);margin-left:var(--space-1-5)}
|
|
39
39
|
.ct-title-cell .meta:empty{display:none}
|
|
40
|
+
/* Hidden (M9 Decision 7; collections under pages, Decision 11): the sidebar row's mark, small and grey, not part of the title. */
|
|
41
|
+
.ct-title-cell .hmark{font-size:var(--text-xs);font-weight:var(--weight-regular);color:var(--text-subtle);white-space:nowrap}
|
|
40
42
|
.ct-extra{margin-left:var(--space-2);font-size:var(--text-xs);color:var(--text-subtle)}
|
|
41
43
|
.ct tr.is-warned .ct-open{color:var(--text-muted)}
|
|
42
44
|
.ct tr.is-current .ct-open{color:var(--select-strong);font-weight:var(--weight-semibold)}
|