caracms 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -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 +103 -12
- 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 +6 -2
- package/src/content.ts +188 -27
- package/src/document.ts +13 -2
- package/src/editor/README.md +3 -0
- package/src/editor/addresses.ts +6 -1
- package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
- package/src/editor/client/arrange/model.ts +659 -0
- package/src/editor/client/arrange/store.ts +335 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +75 -19
- package/src/editor/client/documents/dialogs.tsx +137 -18
- package/src/editor/client/editor.ts +78 -6
- 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/Pane.tsx +3 -0
- package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/client/shell/Stage.tsx +8 -4
- package/src/editor/routes/admin.astro +1 -1
- package/src/editor/routes/arrange.ts +17 -0
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +24 -4
- package/src/editor/server/arrange.ts +245 -0
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +55 -10
- package/src/editor/server/move.ts +300 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/arrange.css +152 -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 +83 -7
- package/src/held.ts +116 -0
- package/src/index.ts +6 -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 +170 -77
- package/src/live/editor.ts +26 -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/page-order.ts +253 -0
- package/src/redirects.ts +64 -1
- package/src/relink.ts +40 -9
- package/src/reserved.ts +3 -2
- package/src/serialize.ts +18 -1
- package/src/slug.ts +15 -4
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arrange pages (Contracts → Apply request): the arrange screen's Apply, both modes.
|
|
3
|
+
*
|
|
4
|
+
* **A security boundary, as move is** (move.ts): the paths come from the browser, so the
|
|
5
|
+
* route is the same POST-JSON-same-origin handler, and every move goes through the
|
|
6
|
+
* many-move engine (`moveMany`: create.ts → `target`, the store's own checks and locks).
|
|
7
|
+
*
|
|
8
|
+
* - `planArrange` expands each root with Change address…'s expansion (move.ts →
|
|
9
|
+
* `movesUnder`): everything under it, except what's under another root, which wins for its
|
|
10
|
+
* own subtree. It checks every `order` key and slug against the Pages collection as it
|
|
11
|
+
* will be once the moves land, and builds the engine's Site Settings edit: the saved order
|
|
12
|
+
* re-keyed after the moves, the request's lists replacing those keys (page-order.ts →
|
|
13
|
+
* `arrangedOrder`), so the order and the redirects land, or roll back, in one write.
|
|
14
|
+
* - With no moves (an order-only Apply, Decision 21), the store writes Site Settings alone,
|
|
15
|
+
* taking its lock in live mode (`DraftsStore.arrange`).
|
|
16
|
+
* - Change address… keeps the order current the same way (`orderEdit`, Decision 6).
|
|
17
|
+
*/
|
|
18
|
+
import type { ResolvedCollection, ResolvedSite } from '../../config.ts';
|
|
19
|
+
import type { DocumentEntry } from '../../content.ts';
|
|
20
|
+
import { parseAnyDocument, type SettingsDocument } from '../../document.ts';
|
|
21
|
+
import type { Person } from '../../live/messages.ts';
|
|
22
|
+
import { arrangedOrder, isOrderedCollection, listingStamp, orderAfterMoves, parsePageOrder, type PageOrder, type PathChange } from '../../page-order.ts';
|
|
23
|
+
import { applyKeyedEdits } from '../../serialize.ts';
|
|
24
|
+
import type { ArrangeRequest, ArrangeResponse } from '../types.ts';
|
|
25
|
+
import { refused } from './create.ts';
|
|
26
|
+
import { movesUnder, type PathMove } from './move.ts';
|
|
27
|
+
import { checkOrigin, seqOf, tabOf, WriteRefused, type WriteContext } from './write.ts';
|
|
28
|
+
|
|
29
|
+
type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
|
|
30
|
+
|
|
31
|
+
/** An Apply's body can carry a whole tree's order and moves. */
|
|
32
|
+
export const MAX_ARRANGE_BODY_BYTES = 256 * 1024;
|
|
33
|
+
|
|
34
|
+
const ARRANGE_SHAPE = '{ moves: [{ collection, fromPath, toPath }…], order: { "<parent path>": ["slug"…] }, redirect?, expect? }';
|
|
35
|
+
|
|
36
|
+
/** Decision 10's sentence when the listing isn't the one the review was planned on. */
|
|
37
|
+
export const CHANGED_MESSAGE = 'The pages changed since you opened the review.';
|
|
38
|
+
|
|
39
|
+
/** Validate an Apply body's shape. Throws WriteRefused 400. */
|
|
40
|
+
export function parseArrangeBody(body: unknown): ArrangeRequest {
|
|
41
|
+
const bad = (why: string) => refused(400, 'bad-request', `Malformed body: ${why}. Send ${ARRANGE_SHAPE}.`);
|
|
42
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw bad('it must be a JSON object');
|
|
43
|
+
const b = body as Record<string, unknown>;
|
|
44
|
+
const extra = Object.keys(b).filter((k) => !['moves', 'order', 'redirect', 'expect'].includes(k));
|
|
45
|
+
if (extra.length) throw bad(`unexpected ${extra.map((k) => JSON.stringify(k.slice(0, 60))).join(', ')}`);
|
|
46
|
+
if (!Array.isArray(b.moves)) throw bad('"moves" must be a list');
|
|
47
|
+
const moves = b.moves.map((m, i) => {
|
|
48
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) throw bad(`moves[${i}] must be { collection, fromPath, toPath }`);
|
|
49
|
+
const r = m as Record<string, unknown>;
|
|
50
|
+
if (Object.keys(r).some((k) => !['collection', 'fromPath', 'toPath'].includes(k))) throw bad(`moves[${i}] must be { collection, fromPath, toPath }`);
|
|
51
|
+
if (typeof r.collection !== 'string' || r.collection === '') throw bad(`moves[${i}].collection must be a collection's name`);
|
|
52
|
+
if (typeof r.fromPath !== 'string' || r.fromPath === '') throw bad(`moves[${i}].fromPath must be the page's path`);
|
|
53
|
+
if (typeof r.toPath !== 'string') throw bad(`moves[${i}].toPath must be the page's new path`);
|
|
54
|
+
if (r.fromPath.length > 512 || r.toPath.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
|
|
55
|
+
return { collection: r.collection, fromPath: r.fromPath, toPath: r.toPath };
|
|
56
|
+
});
|
|
57
|
+
if (!b.order || typeof b.order !== 'object' || Array.isArray(b.order)) throw bad('"order" must be an object of lists');
|
|
58
|
+
const order: PageOrder = {};
|
|
59
|
+
for (const [key, list] of Object.entries(b.order as Record<string, unknown>)) {
|
|
60
|
+
if (!Array.isArray(list) || !list.every((s) => typeof s === 'string' && s !== '')) throw bad(`order[${JSON.stringify(key.slice(0, 60))}] must be a list of slugs`);
|
|
61
|
+
if (key.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
|
|
62
|
+
order[key] = [...new Set(list as string[])];
|
|
63
|
+
}
|
|
64
|
+
if (b.redirect !== undefined && typeof b.redirect !== 'boolean') throw bad('"redirect" must be true or false');
|
|
65
|
+
let expect: Record<string, string> | undefined;
|
|
66
|
+
if (b.expect !== undefined) {
|
|
67
|
+
if (!b.expect || typeof b.expect !== 'object' || Array.isArray(b.expect) || !Object.values(b.expect).every((v) => typeof v === 'string')) throw bad('"expect" must be { "<collection>": "<stamp>" }');
|
|
68
|
+
expect = { ...(b.expect as Record<string, string>) };
|
|
69
|
+
}
|
|
70
|
+
return { moves, order, ...(b.redirect === false ? { redirect: false } : {}), ...(expect ? { expect } : {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const orderedCollection = (site: Pick<Site, 'collections'>): ResolvedCollection | undefined => Object.values(site.collections).find((c) => isOrderedCollection(c));
|
|
74
|
+
const settingsCollection = (site: Pick<Site, 'collections'>): ResolvedCollection | undefined => Object.values(site.collections).find((c) => c.kind === 'settings');
|
|
75
|
+
|
|
76
|
+
/** Site Settings' `pageOrder` in its text (`{}`: none, or the text isn't JSON). */
|
|
77
|
+
export function orderIn(text: string | null): PageOrder {
|
|
78
|
+
if (text === null) return {};
|
|
79
|
+
try {
|
|
80
|
+
const raw = JSON.parse(text) as unknown;
|
|
81
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? parsePageOrder((raw as Record<string, unknown>).pageOrder) : {};
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Site Settings' text with `pageOrder` set to `order` (taken out when it's empty), every
|
|
89
|
+
* other key as it was; `text` null: a new file. Unchanged text when the order is the same,
|
|
90
|
+
* or when the text isn't JSON (the editor warns about that file).
|
|
91
|
+
*/
|
|
92
|
+
export function settingsWithOrder(site: Pick<Site, 'collections' | 'blocks'>, text: string | null, order: PageOrder): string | null {
|
|
93
|
+
const collection = settingsCollection(site);
|
|
94
|
+
if (!collection) return text;
|
|
95
|
+
let doc: SettingsDocument | null = null;
|
|
96
|
+
if (text !== null) {
|
|
97
|
+
try {
|
|
98
|
+
doc = parseAnyDocument(JSON.parse(text), site, collection) as SettingsDocument;
|
|
99
|
+
} catch {
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (doc === null && Object.keys(order).length === 0) return text;
|
|
104
|
+
return applyKeyedEdits(text, { title: doc?.title ?? 'Site Settings', data: { ...(doc?.data ?? {}), pageOrder: order } }, collection);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The sentence an order-only Apply is refused with when Site Settings, which holds the order, can't be read. */
|
|
108
|
+
export const UNREADABLE_SETTINGS_MESSAGE = "Site Settings isn't valid JSON, so the new order can't be saved there. Nothing was changed: fix Site Settings, then apply again.";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* An order-only Apply (Decision 21) writes nothing but Site Settings, so one that can't be read
|
|
112
|
+
* is refused (422 invalid-json) rather than answered `{ ok: true }` with the order dropped.
|
|
113
|
+
* `text` null (no file yet) is fine: the order makes a new one.
|
|
114
|
+
*/
|
|
115
|
+
export function assertSettingsReadable(site: Pick<Site, 'collections' | 'blocks'>, text: string | null): void {
|
|
116
|
+
const collection = settingsCollection(site);
|
|
117
|
+
if (!collection || text === null) return;
|
|
118
|
+
try {
|
|
119
|
+
parseAnyDocument(JSON.parse(text), site, collection);
|
|
120
|
+
} catch {
|
|
121
|
+
throw refused(422, 'invalid-json', UNREADABLE_SETTINGS_MESSAGE);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The ordered collection's path changes in a list of moves. */
|
|
126
|
+
function pathChanges(site: Pick<Site, 'collections'>, moves: readonly PathMove[]): PathChange[] {
|
|
127
|
+
const pages = orderedCollection(site);
|
|
128
|
+
return pages ? moves.filter((m) => m.collection === pages.name).map((m) => ({ from: m.path, to: m.to })) : [];
|
|
129
|
+
}
|
|
130
|
+
const pathsOf = (site: Pick<Site, 'collections'>, documents: readonly DocumentEntry[]) => {
|
|
131
|
+
const pages = orderedCollection(site);
|
|
132
|
+
return pages ? documents.filter((d) => d.collection === pages.name).map((d) => d.path) : [];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Change address… (Decision 6): the engine's Site Settings edit that keeps a saved order current after these moves. */
|
|
136
|
+
export function orderEdit(site: Site, moves: readonly PathMove[], documents: readonly DocumentEntry[]): (text: string) => string {
|
|
137
|
+
const changes = pathChanges(site, moves);
|
|
138
|
+
const before = pathsOf(site, documents);
|
|
139
|
+
return (text) => {
|
|
140
|
+
const saved = orderIn(text);
|
|
141
|
+
if (changes.length === 0 || Object.keys(saved).length === 0) return text;
|
|
142
|
+
return settingsWithOrder(site, text, orderAfterMoves(saved, changes, before)) ?? text;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ArrangePlan {
|
|
147
|
+
/** Every document whose path changes, roots first (each root, then what it carries); empty for an order-only Apply. */
|
|
148
|
+
moves: PathMove[];
|
|
149
|
+
/** Site Settings' text → its text with the arranged order (the engine's `editSettings`, or the order-only write). */
|
|
150
|
+
edit: (text: string | null) => string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The Apply's plan against the listing as it is (`documents`): the roots expanded, the
|
|
155
|
+
* order checked against the Pages collection as it will be. Throws WriteRefused: the
|
|
156
|
+
* engine's own refusals for a root (move.ts → `movesUnder`), `not-found` (404) for an
|
|
157
|
+
* order entry that names nothing once the moves land.
|
|
158
|
+
*/
|
|
159
|
+
export function planArrange(site: Site, request: ArrangeRequest, documents: readonly DocumentEntry[]): ArrangePlan {
|
|
160
|
+
// Decision 10: the review's listing, collection by collection, before anything is planned.
|
|
161
|
+
for (const [collection, stamp] of Object.entries(request.expect ?? {})) {
|
|
162
|
+
const paths = documents.filter((d) => d.collection === collection).map((d) => d.path);
|
|
163
|
+
if (listingStamp(paths) !== stamp) throw refused(409, 'changed', CHANGED_MESSAGE);
|
|
164
|
+
}
|
|
165
|
+
const key = (m: { collection: string; path: string }) => `${m.collection}\0${m.path}`;
|
|
166
|
+
// Shallowest first, so an inner root wins for its own subtree.
|
|
167
|
+
const roots = [...request.moves].sort((a, b) => a.fromPath.split('/').length - b.fromPath.split('/').length);
|
|
168
|
+
const seen = new Set<string>();
|
|
169
|
+
for (const r of roots) {
|
|
170
|
+
const k = key({ collection: r.collection, path: r.fromPath });
|
|
171
|
+
if (seen.has(k)) throw refused(400, 'bad-request', `${r.fromPath} is in the plan twice.`);
|
|
172
|
+
seen.add(k);
|
|
173
|
+
}
|
|
174
|
+
const planned = new Map<string, PathMove>();
|
|
175
|
+
for (const r of roots) {
|
|
176
|
+
for (const m of movesUnder(site, { collection: r.collection, path: r.fromPath, to: r.toPath }, documents)) planned.set(key(m), m);
|
|
177
|
+
}
|
|
178
|
+
// Roots in the order asked, each followed by what it carries (what the innermost root above it carries).
|
|
179
|
+
const ownerOf = (m: PathMove) =>
|
|
180
|
+
request.moves.filter((o) => o.collection === m.collection && (m.path === o.fromPath || m.path.startsWith(`${o.fromPath}/`))).sort((a, b) => b.fromPath.length - a.fromPath.length)[0]!;
|
|
181
|
+
const ordered: PathMove[] = [];
|
|
182
|
+
for (const r of request.moves) {
|
|
183
|
+
ordered.push(planned.get(key({ collection: r.collection, path: r.fromPath }))!);
|
|
184
|
+
for (const m of planned.values()) if (m.path !== r.fromPath && ownerOf(m) === r) ordered.push(m);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The Pages collection once the moves land: every `order` key and slug names something there.
|
|
188
|
+
const changes = pathChanges(site, ordered);
|
|
189
|
+
const before = pathsOf(site, documents);
|
|
190
|
+
const moving = new Map(changes.map((c) => [c.from, c.to]));
|
|
191
|
+
const after = before.map((p) => moving.get(p) ?? p);
|
|
192
|
+
const known = new Set<string>(['']);
|
|
193
|
+
for (const p of after) {
|
|
194
|
+
const parts = p.split('/');
|
|
195
|
+
for (let i = 1; i <= parts.length; i++) known.add(parts.slice(0, i).join('/'));
|
|
196
|
+
}
|
|
197
|
+
for (const [parent, slugs] of Object.entries(request.order)) {
|
|
198
|
+
if (!known.has(parent)) throw refused(404, 'not-found', `There's no page or group at /${parent} any more.`);
|
|
199
|
+
for (const slug of slugs) {
|
|
200
|
+
const path = parent === '' ? slug : `${parent}/${slug}`;
|
|
201
|
+
if (!known.has(path)) throw refused(404, 'not-found', `There's no page or group at /${path} any more.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const edit = (text: string | null) => settingsWithOrder(site, text, arrangedOrder(orderIn(text), request.order, changes, before));
|
|
205
|
+
return { moves: ordered, edit };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── The route ──────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
|
|
211
|
+
new Response(JSON.stringify(body), {
|
|
212
|
+
status,
|
|
213
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/** What the route needs of the drafts store. */
|
|
217
|
+
export interface Arranges {
|
|
218
|
+
arrange(request: ArrangeRequest, context?: WriteContext): Promise<ArrangeResponse>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** POST /_caracms/arrange: a Request in, a Response out, through the mode's store (move.ts's handling, the same checks). */
|
|
222
|
+
export async function handleArrange(request: Request, store: Arranges, who: { person?: Person | null } = {}): Promise<Response> {
|
|
223
|
+
try {
|
|
224
|
+
if (request.method !== 'POST') throw new WriteRefused(405, 'The arrange route only takes POST.');
|
|
225
|
+
checkOrigin(request);
|
|
226
|
+
const type = request.headers.get('content-type') ?? '';
|
|
227
|
+
if (!/^application\/json\s*(;|$)/i.test(type)) throw new WriteRefused(415, 'Send JSON (Content-Type: application/json).');
|
|
228
|
+
const length = Number(request.headers.get('content-length') ?? 0);
|
|
229
|
+
if (length > MAX_ARRANGE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
|
|
230
|
+
const raw = await request.text();
|
|
231
|
+
if (Buffer.byteLength(raw) > MAX_ARRANGE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
|
|
232
|
+
let body: unknown;
|
|
233
|
+
try {
|
|
234
|
+
body = JSON.parse(raw);
|
|
235
|
+
} catch {
|
|
236
|
+
throw refused(400, 'bad-request', 'Malformed body: it isn\'t valid JSON.');
|
|
237
|
+
}
|
|
238
|
+
const context: WriteContext = { person: who.person ?? null, tab: tabOf(request), release: false, seq: seqOf(request) };
|
|
239
|
+
return json(200, await store.arrange(parseArrangeBody(body), context));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body }, error.status === 405 ? { Allow: 'POST' } : {});
|
|
242
|
+
console.error('[caracms] arrange failed:', error);
|
|
243
|
+
return json(500, { error: `That didn't work (${error instanceof Error ? error.message : String(error)}).` });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -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(
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Everything reads at request time (never imported, Decision 5), and keeps no state.
|
|
8
8
|
*/
|
|
9
|
-
import { stat } from 'node:fs/promises';
|
|
9
|
+
import { stat, writeFile } from 'node:fs/promises';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { SITE_SETTINGS_FILE, type ResolvedSite } from '../../config.ts';
|
|
12
12
|
import { diskSource, listDocuments, readDocument, type ContentSource } from '../../content.ts';
|
|
13
|
-
import type { AnyOpenedDocument, CreateRequest, CreateResponse, DeleteRequest, DeleteResponse, ListedDocument, MoveRequest, MoveResponse, OpenedDocument, ReplaceFileRequest, ReplaceFileResponse } from '../types.ts';
|
|
13
|
+
import type { AnyOpenedDocument, ArrangeRequest, ArrangeResponse, 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 { assertSettingsReadable, orderEdit, planArrange } from './arrange.ts';
|
|
16
|
+
import { movesChanges, movesOnDisk, moveResponse, movesUnder, planMoves, settingsWithoutRedirectFrom, type ManyMoveRequest } from './move.ts';
|
|
16
17
|
import { replaceChanges, replaceResponse } from './replace.ts';
|
|
17
18
|
import { applyWrite, fileVersion, writeAtomic, type WriteContext, type WriteRequest, type WriteResponse } from './write.ts';
|
|
18
19
|
|
|
@@ -53,11 +54,26 @@ export interface DraftsStore {
|
|
|
53
54
|
/**
|
|
54
55
|
* Change a document's address (pre-M7 item 8; move.ts has the rules, the same in both
|
|
55
56
|
* 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.
|
|
57
|
+
* Settings gets a redirect from the old address. Collections under pages (Decision 9):
|
|
58
|
+
* everything under a page moves with it, through `moveMany`. Throws WriteRefused.
|
|
59
59
|
*/
|
|
60
60
|
move(request: MoveRequest, context?: WriteContext): Promise<MoveResponse>;
|
|
61
|
+
/**
|
|
62
|
+
* The many-move engine (collections under pages, Contracts): every document in the
|
|
63
|
+
* request at its new path, and each held document whose holder's address changes,
|
|
64
|
+
* checked as a whole, then applied all or nothing, each linking file rewritten once and
|
|
65
|
+
* Site Settings written once and last, with every redirect and the caller's own edit.
|
|
66
|
+
* Local: on disk, put back on a failure. Live: every lock taken up front, every change an
|
|
67
|
+
* autosave, all rolled back on a failure, all going out at the next Publish. Throws WriteRefused.
|
|
68
|
+
*/
|
|
69
|
+
moveMany(request: ManyMoveRequest, context?: WriteContext): Promise<MoveResponse>;
|
|
70
|
+
/**
|
|
71
|
+
* Arrange pages (Contracts → Apply request): the roots expanded and checked (arrange.ts →
|
|
72
|
+
* `planArrange`), then the moves through `moveMany` with the arranged order as its Site
|
|
73
|
+
* Settings edit, landing or rolling back together; with no moves, Site Settings alone
|
|
74
|
+
* (live mode: under its lock, Decision 21). Throws WriteRefused.
|
|
75
|
+
*/
|
|
76
|
+
arrange(request: ArrangeRequest, context?: WriteContext): Promise<ArrangeResponse>;
|
|
61
77
|
/**
|
|
62
78
|
* M6c G4 (Decision 9): Replace… on a file. Every link to `from`, in every document, points
|
|
63
79
|
* at `to` (replace.ts has the rules, the same in both stores). Local: each file written
|
|
@@ -71,6 +87,15 @@ type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
|
|
|
71
87
|
|
|
72
88
|
/** The local store: the content files on disk. */
|
|
73
89
|
export function localDraftsStore(site: Site): DraftsStore {
|
|
90
|
+
const moveMany = async (request: ManyMoveRequest): Promise<MoveResponse> => {
|
|
91
|
+
for (const m of request.moves) await confineOnDisk(site, target(site, m.collection, m.path).file);
|
|
92
|
+
const documents = await listDocuments(site);
|
|
93
|
+
const plans = planMoves(site, request.moves, documents);
|
|
94
|
+
const read = (file: string) => diskSource.readText(join(site.root, file));
|
|
95
|
+
const changes = await movesChanges(site, plans, documents, read, () => request.redirect !== false, request.editSettings);
|
|
96
|
+
await movesOnDisk(site, changes);
|
|
97
|
+
return moveResponse(plans, changes);
|
|
98
|
+
};
|
|
74
99
|
return {
|
|
75
100
|
async list() {
|
|
76
101
|
const entries = await listDocuments(site);
|
|
@@ -109,10 +134,30 @@ export function localDraftsStore(site: Site): DraftsStore {
|
|
|
109
134
|
// The file first (a link out of the collection is refused before anything else), then the rules.
|
|
110
135
|
await confineOnDisk(site, target(site, request.collection, request.path).file);
|
|
111
136
|
const documents = await listDocuments(site);
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
137
|
+
const moves = movesUnder(site, request, documents);
|
|
138
|
+
// Arrange pages (Decision 6): a saved order follows the move, in the same Site Settings write.
|
|
139
|
+
return moveMany({ moves, ...(request.redirect === false ? { redirect: false } : {}), editSettings: orderEdit(site, moves, documents) });
|
|
140
|
+
},
|
|
141
|
+
moveMany,
|
|
142
|
+
async arrange(request) {
|
|
143
|
+
// The files first (a link out of the collection is refused before anything else), then the plan.
|
|
144
|
+
for (const m of request.moves) await confineOnDisk(site, target(site, m.collection, m.fromPath).file);
|
|
145
|
+
const plan = planArrange(site, request, await listDocuments(site));
|
|
146
|
+
// An unreadable Site Settings would drop the order and the redirects without a word: refuse every Apply.
|
|
147
|
+
assertSettingsReadable(site, await diskSource.readText(join(site.root, SITE_SETTINGS_FILE)));
|
|
148
|
+
if (plan.moves.length) {
|
|
149
|
+
await moveMany({ moves: plan.moves, ...(request.redirect === false ? { redirect: false } : {}), editSettings: (text) => plan.edit(text) ?? text });
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
const file = join(site.root, SITE_SETTINGS_FILE);
|
|
153
|
+
const before = await diskSource.readText(file);
|
|
154
|
+
assertSettingsReadable(site, before);
|
|
155
|
+
const after = plan.edit(before);
|
|
156
|
+
if (after !== null && after !== before) {
|
|
157
|
+
if (before === null) await writeFile(file, after, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
|
|
158
|
+
else await writeAtomic(file, after);
|
|
159
|
+
}
|
|
160
|
+
return { ok: true };
|
|
116
161
|
},
|
|
117
162
|
async replaceFile(request) {
|
|
118
163
|
const changes = await replaceChanges(site, request, await listDocuments(site), (file) => diskSource.readText(join(site.root, file)));
|