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,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Pages tree's saved order (arrange pages, Decisions 6, 7 and 19). Browser-safe: no
|
|
3
|
+
* node or disk imports, so the editor's client uses the same rules as the server.
|
|
4
|
+
*
|
|
5
|
+
* - **`PageOrder`** is Site Settings' `pageOrder`: a parent's path (`""` for the top level)
|
|
6
|
+
* → its children's slugs, pages and groups alike, in order. Absent means "the rule".
|
|
7
|
+
* - **The order of one level** (`orderSiblings`, Decision 7): Home first, always; then the
|
|
8
|
+
* siblings the saved list names, in that order (an unknown slug is ignored); then the
|
|
9
|
+
* unlisted ones, those with pages or a held collection under them first, then A–Z by
|
|
10
|
+
* label (content.ts's collator).
|
|
11
|
+
* - **After moves** (`orderAfterMoves`, Decision 6): a moved page's slug leaves its old
|
|
12
|
+
* parent's list and goes at the end of its new parent's (when that level has a saved
|
|
13
|
+
* list; with none, the rule places it); every key at or under a moved path is re-keyed,
|
|
14
|
+
* at any depth; a group renamed (every page under it moving to the same place under a new
|
|
15
|
+
* folder) changes its key and its slug in place; a list left empty is dropped.
|
|
16
|
+
* - **The Apply's merge** (`arrangedOrder`): the re-keyed saved order, then the request's
|
|
17
|
+
* own lists replacing those keys.
|
|
18
|
+
* - **The stamp** (`pageOrderStamp`, Decision 18): a short hash of the order, so another
|
|
19
|
+
* tab sees an order-only change on its next poll.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Site Settings' `pageOrder`: parent path (`""` is the top level) → its children's slugs in order. */
|
|
23
|
+
export type PageOrder = Record<string, string[]>;
|
|
24
|
+
|
|
25
|
+
/** One path that changes: a page's (or a group's folder's) path in the Pages collection, before → after. */
|
|
26
|
+
export interface PathChange {
|
|
27
|
+
from: string;
|
|
28
|
+
to: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The collection the saved order is for: the nested section builder at `/` (Pages). */
|
|
32
|
+
export function isOrderedCollection(collection: { nested?: boolean; urlBase: string | null }): boolean {
|
|
33
|
+
return !!collection.nested && collection.urlBase === '/';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A path's parent (`''` at the top level). */
|
|
37
|
+
export const parentOf = (path: string): string => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
|
|
38
|
+
/** A path's last part. */
|
|
39
|
+
export const slugOf = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
|
|
40
|
+
const isHome = (path: string) => path === 'index' || path.endsWith('/index');
|
|
41
|
+
const under = (path: string, folder: string) => path === folder || path.startsWith(`${folder}/`);
|
|
42
|
+
|
|
43
|
+
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
|
|
44
|
+
/** A–Z by label, then by path (content.ts's order). */
|
|
45
|
+
export function byLabel(a: { label: string; path: string }, b: { label: string; path: string }): number {
|
|
46
|
+
return collator.compare(a.label, b.label) || collator.compare(a.path, b.path);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One level of the tree in order (Decision 7): Home first; then the siblings `saved` names,
|
|
51
|
+
* in its order; then the rest, those `filled` (pages or a held collection under them) first,
|
|
52
|
+
* then A–Z by label. `saved` undefined: the rule alone.
|
|
53
|
+
*/
|
|
54
|
+
export function orderSiblings<T extends { path: string; label: string }>(items: readonly T[], saved: readonly string[] | undefined, filled: (item: T) => boolean): T[] {
|
|
55
|
+
const rank = new Map<string, number>();
|
|
56
|
+
(saved ?? []).forEach((slug, i) => {
|
|
57
|
+
if (!rank.has(slug)) rank.set(slug, i);
|
|
58
|
+
});
|
|
59
|
+
return [...items].sort((a, b) => {
|
|
60
|
+
const ha = isHome(a.path);
|
|
61
|
+
const hb = isHome(b.path);
|
|
62
|
+
if (ha !== hb) return ha ? -1 : 1;
|
|
63
|
+
const ra = rank.get(slugOf(a.path));
|
|
64
|
+
const rb = rank.get(slugOf(b.path));
|
|
65
|
+
if (ra !== undefined || rb !== undefined) {
|
|
66
|
+
if (ra === undefined) return 1;
|
|
67
|
+
if (rb === undefined) return -1;
|
|
68
|
+
if (ra !== rb) return ra - rb;
|
|
69
|
+
}
|
|
70
|
+
const fa = filled(a);
|
|
71
|
+
const fb = filled(b);
|
|
72
|
+
if (fa !== fb) return fa ? -1 : 1;
|
|
73
|
+
return byLabel(a, b);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `pageOrder` as read from a file: an object of string lists; anything else dropped, each list without repeats, empty lists left out. */
|
|
78
|
+
export function parsePageOrder(raw: unknown): PageOrder {
|
|
79
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
|
80
|
+
const out: PageOrder = {};
|
|
81
|
+
for (const [key, list] of Object.entries(raw as Record<string, unknown>)) {
|
|
82
|
+
if (!Array.isArray(list)) continue;
|
|
83
|
+
const slugs = [...new Set(list.filter((s): s is string => typeof s === 'string' && s !== ''))];
|
|
84
|
+
if (slugs.length) out[key] = slugs;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The order without its empty lists (and without repeats in a list). */
|
|
90
|
+
function tidy(order: PageOrder): PageOrder {
|
|
91
|
+
const out: PageOrder = {};
|
|
92
|
+
for (const [key, list] of Object.entries(order)) {
|
|
93
|
+
const slugs = [...new Set(list)];
|
|
94
|
+
if (slugs.length) out[key] = slugs;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The groups the moves rename: a folder with no page of its own (in `pathsBefore`, the Pages
|
|
101
|
+
* collection's paths before the moves) all of whose pages move to the same place under one
|
|
102
|
+
* new folder that's free once the moves land (a swap is fine). Topmost only.
|
|
103
|
+
*/
|
|
104
|
+
export function groupRenames(pathsBefore: readonly string[], moves: readonly PathChange[]): PathChange[] {
|
|
105
|
+
const pages = new Set(pathsBefore);
|
|
106
|
+
const moving = new Map(moves.map((m) => [m.from, m.to]));
|
|
107
|
+
const folders = new Set<string>();
|
|
108
|
+
for (const p of pathsBefore) {
|
|
109
|
+
const parts = p.split('/');
|
|
110
|
+
for (let i = 1; i < parts.length; i++) folders.add(parts.slice(0, i).join('/'));
|
|
111
|
+
}
|
|
112
|
+
const renames: PathChange[] = [];
|
|
113
|
+
for (const group of [...folders].filter((f) => !pages.has(f)).sort((a, b) => a.split('/').length - b.split('/').length)) {
|
|
114
|
+
if (renames.some((r) => under(group, r.from))) continue;
|
|
115
|
+
const inside = pathsBefore.filter((p) => p.startsWith(`${group}/`));
|
|
116
|
+
let to: string | null = null;
|
|
117
|
+
const same = inside.every((p) => {
|
|
118
|
+
const target = moving.get(p);
|
|
119
|
+
const rest = p.slice(group.length);
|
|
120
|
+
if (target === undefined || !target.endsWith(rest)) return false;
|
|
121
|
+
const folder = target.slice(0, target.length - rest.length);
|
|
122
|
+
if (to === null) to = folder;
|
|
123
|
+
return folder === to;
|
|
124
|
+
});
|
|
125
|
+
if (!same || to === null || to === '' || to === group || under(to, group)) continue;
|
|
126
|
+
const into = to as string;
|
|
127
|
+
// The new folder is free once the moves land: whatever's there now moves away.
|
|
128
|
+
if (pathsBefore.some((p) => under(p, into) && !moving.has(p))) continue;
|
|
129
|
+
renames.push({ from: group, to: into });
|
|
130
|
+
}
|
|
131
|
+
return renames;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The saved order once these moves land (Decision 6). `moves`: every page path that changes
|
|
136
|
+
* (Change address…'s or an Apply's, expanded: the pages under a moved page included);
|
|
137
|
+
* `pathsBefore`: the Pages collection's paths before them, for the groups they rename.
|
|
138
|
+
*/
|
|
139
|
+
export function orderAfterMoves(order: PageOrder, moves: readonly PathChange[], pathsBefore: readonly string[] = []): PageOrder {
|
|
140
|
+
const all = [...moves.filter((m) => m.from !== m.to), ...groupRenames(pathsBefore, moves)].sort((a, b) => a.from.split('/').length - b.from.split('/').length);
|
|
141
|
+
// The moves that aren't carried by a move above them: those change a list.
|
|
142
|
+
const units: PathChange[] = [];
|
|
143
|
+
for (const m of all) {
|
|
144
|
+
if (units.some((u) => u.from === m.from)) continue;
|
|
145
|
+
const carried = units.some((u) => m.from.startsWith(`${u.from}/`) && m.to === u.to + m.from.slice(u.from.length));
|
|
146
|
+
if (!carried) units.push(m);
|
|
147
|
+
}
|
|
148
|
+
if (units.length === 0) return tidy(order);
|
|
149
|
+
const rekey = (path: string) => {
|
|
150
|
+
let best: PathChange | null = null;
|
|
151
|
+
for (const u of units) if (under(path, u.from) && (!best || u.from.length > best.from.length)) best = u;
|
|
152
|
+
return best ? best.to + path.slice(best.from.length) : path;
|
|
153
|
+
};
|
|
154
|
+
const out: PageOrder = {};
|
|
155
|
+
for (const [key, list] of Object.entries(order)) {
|
|
156
|
+
const into = (out[rekey(key)] ??= []);
|
|
157
|
+
for (const slug of list) if (!into.includes(slug)) into.push(slug);
|
|
158
|
+
}
|
|
159
|
+
// Each unit's place in its old list, marked first, so a swap's slugs can't meet.
|
|
160
|
+
const marks = units.map((_, i) => `\u0000${i}`);
|
|
161
|
+
units.forEach((u, i) => {
|
|
162
|
+
const list = out[rekey(parentOf(u.from))];
|
|
163
|
+
const at = list ? list.indexOf(slugOf(u.from)) : -1;
|
|
164
|
+
if (at >= 0) list![at] = marks[i]!;
|
|
165
|
+
});
|
|
166
|
+
units.forEach((u, i) => {
|
|
167
|
+
const oldKey = rekey(parentOf(u.from));
|
|
168
|
+
const newKey = parentOf(u.to);
|
|
169
|
+
const slug = slugOf(u.to);
|
|
170
|
+
const old = out[oldKey];
|
|
171
|
+
const at = old ? old.indexOf(marks[i]!) : -1;
|
|
172
|
+
if (oldKey === newKey) {
|
|
173
|
+
// Renamed in place (a group rename, or a page's own slug): it keeps its place.
|
|
174
|
+
if (at >= 0) {
|
|
175
|
+
if (old!.includes(slug)) old!.splice(at, 1);
|
|
176
|
+
else old![at] = slug;
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (at >= 0) old!.splice(at, 1);
|
|
181
|
+
const into = out[newKey];
|
|
182
|
+
if (into && !into.includes(slug)) into.push(slug);
|
|
183
|
+
});
|
|
184
|
+
for (const list of Object.values(out)) for (let i = list.length - 1; i >= 0; i--) if (list[i]!.startsWith('\u0000')) list.splice(i, 1);
|
|
185
|
+
return tidy(out);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The Apply's order (Contracts): the saved order re-keyed after the moves, then the request's lists replacing those keys, empty lists dropped. */
|
|
189
|
+
export function arrangedOrder(saved: PageOrder, requested: PageOrder, moves: readonly PathChange[], pathsBefore: readonly string[] = []): PageOrder {
|
|
190
|
+
return tidy({ ...orderAfterMoves(saved, moves, pathsBefore), ...requested });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The saved order's entries that name nothing: a key that's no page or group, or a slug that's no child of its key. `paths`: the Pages collection's paths. */
|
|
194
|
+
export function unknownOrderEntries(order: PageOrder, paths: readonly string[]): { key: string; slug: string | null }[] {
|
|
195
|
+
const known = new Set<string>(['']);
|
|
196
|
+
for (const p of paths) {
|
|
197
|
+
const parts = p.split('/');
|
|
198
|
+
for (let i = 1; i <= parts.length; i++) known.add(parts.slice(0, i).join('/'));
|
|
199
|
+
}
|
|
200
|
+
const out: { key: string; slug: string | null }[] = [];
|
|
201
|
+
for (const [key, list] of Object.entries(order)) {
|
|
202
|
+
if (!known.has(key)) {
|
|
203
|
+
out.push({ key, slug: null });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
for (const slug of list) if (!known.has(key === '' ? slug : `${key}/${slug}`)) out.push({ key, slug });
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The order without the entries `unknownOrderEntries` finds, and how many slugs went. */
|
|
212
|
+
export function prunedOrder(order: PageOrder, paths: readonly string[]): { order: PageOrder; pruned: number } {
|
|
213
|
+
const unknown = unknownOrderEntries(order, paths);
|
|
214
|
+
if (unknown.length === 0) return { order, pruned: 0 };
|
|
215
|
+
const out: PageOrder = {};
|
|
216
|
+
let pruned = 0;
|
|
217
|
+
for (const [key, list] of Object.entries(order)) {
|
|
218
|
+
if (unknown.some((u) => u.key === key && u.slug === null)) {
|
|
219
|
+
pruned += list.length;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const kept = list.filter((slug) => !unknown.some((u) => u.key === key && u.slug === slug));
|
|
223
|
+
pruned += list.length - kept.length;
|
|
224
|
+
if (kept.length) out[key] = kept;
|
|
225
|
+
}
|
|
226
|
+
return { order: out, pruned };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** FNV-1a over a text, in base 36. */
|
|
230
|
+
function fnv(text: string): string {
|
|
231
|
+
let hash = 0x811c9dc5;
|
|
232
|
+
for (let i = 0; i < text.length; i++) {
|
|
233
|
+
hash ^= text.charCodeAt(i);
|
|
234
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
235
|
+
}
|
|
236
|
+
return hash.toString(36);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Decision 10: a short hash of a collection's document paths, sorted (FNV-1a), so the Apply
|
|
241
|
+
* can tell the listing it was planned on from the one it lands on (`ArrangeRequest.expect`).
|
|
242
|
+
*/
|
|
243
|
+
export function listingStamp(paths: readonly string[]): string {
|
|
244
|
+
return fnv(JSON.stringify([...paths].sort()));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Decision 18: a short hash of the order (FNV-1a over its keys in order); `""` when there's none. */
|
|
248
|
+
export function pageOrderStamp(order: PageOrder | null | undefined): string {
|
|
249
|
+
if (!order) return '';
|
|
250
|
+
const keys = Object.keys(order).sort();
|
|
251
|
+
if (keys.length === 0) return '';
|
|
252
|
+
return fnv(JSON.stringify(keys.map((k) => [k, order[k]])));
|
|
253
|
+
}
|
package/src/redirects.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Redirects (pre-M7 item 8): Site Settings' `redirects`, a list of `{ from, to }`
|
|
3
3
|
* site addresses. Changing a document's address adds one, from the old address to
|
|
4
|
-
* the new; the package serves them as 301s on the public site (redirect-middleware.ts).
|
|
4
|
+
* the new, unless it's asked not to (move.ts); the package serves them as 301s on the public site (redirect-middleware.ts).
|
|
5
5
|
* Browser-safe (no imports beyond links.ts): the Redirects section reads them too.
|
|
6
6
|
*
|
|
7
7
|
* parseRedirects(raw) the list as the file holds it, cleaned
|
|
8
8
|
* redirectsAfterMove(list, from, to, add) the list once a document moves
|
|
9
|
+
* redirectsAfterMoves(list, moves, homes?) the list once several move at once (swaps, chains)
|
|
9
10
|
* redirectFor(list, pathname) where a request for `pathname` goes, or null
|
|
10
11
|
*
|
|
11
12
|
* - **Both ends are `/paths`**, without a trailing slash (`/` stays `/`). An entry that
|
|
@@ -64,6 +65,68 @@ export function redirectsAfterMove(list: readonly Redirect[], from: string, to:
|
|
|
64
65
|
return next;
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A document's live address (the one the public site served at the last Publish) and
|
|
70
|
+
* where it is once the plan has landed: live mode's documents moved since the last Publish
|
|
71
|
+
* (autosaves.ts → `recordAddresses`). `redirectsAfterMoves` adds `live → now` when a move
|
|
72
|
+
* in the plan vacates `live`.
|
|
73
|
+
*/
|
|
74
|
+
export interface LiveHome {
|
|
75
|
+
live: string;
|
|
76
|
+
now: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The homes whose live address a move in the plan vacates and no document then takes: each adds `live → now`. */
|
|
80
|
+
function strandedHomes(moves: readonly { from: string; to: string }[], homes: readonly LiveHome[]): Redirect[] {
|
|
81
|
+
const taken = (address: string) => moves.some((m) => same(m.to, address));
|
|
82
|
+
const out: Redirect[] = [];
|
|
83
|
+
for (const h of homes) {
|
|
84
|
+
if (same(h.live, h.now) || taken(h.live) || !moves.some((m) => same(m.from, h.live))) continue;
|
|
85
|
+
if (out.some((r) => same(r.from, h.live))) continue;
|
|
86
|
+
out.push({ from: h.live, to: h.now });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The list once several documents move at once (the many-move engine): every move is
|
|
93
|
+
* taken against the list before the plan, so a swap or a chain comes out as its
|
|
94
|
+
* documents end up. Entries to an old address go to that document's new one (chains
|
|
95
|
+
* collapse, one step: `X → A` becomes `X → B` when A moves to B); an entry from an
|
|
96
|
+
* address a document now occupies goes (a page is there); and, per move with `add`,
|
|
97
|
+
* `from → to` is added, unless another document now sits at `from` (in a swap neither
|
|
98
|
+
* is added). With no address both vacated and taken, this is `redirectsAfterMove` per
|
|
99
|
+
* move, in order.
|
|
100
|
+
*
|
|
101
|
+
* `homes` (live mode, and only when the move leaves redirects): when a move vacates an
|
|
102
|
+
* address that is another document's live address (a chain before a Publish left that
|
|
103
|
+
* document without a redirect from it, since a document sat there), `live → now` is added
|
|
104
|
+
* too, replacing any entry from that address, so the public site's old address still
|
|
105
|
+
* finds its document after the Publish.
|
|
106
|
+
*/
|
|
107
|
+
export function redirectsAfterMoves(list: readonly Redirect[], moves: readonly { from: string; to: string; add: boolean }[], homes: readonly LiveHome[] = []): Redirect[] {
|
|
108
|
+
const taken = (address: string) => moves.some((m) => same(m.to, address));
|
|
109
|
+
const stranded = strandedHomes(moves, homes);
|
|
110
|
+
const replaced = (address: string) => moves.some((m) => m.add && same(m.from, address)) || stranded.some((r) => same(r.from, address));
|
|
111
|
+
const next = list
|
|
112
|
+
.filter((r) => !taken(r.from) && !replaced(r.from))
|
|
113
|
+
.map((r) => {
|
|
114
|
+
const m = moves.find((m) => same(r.to, m.from));
|
|
115
|
+
return m ? { from: r.from, to: m.to } : r;
|
|
116
|
+
})
|
|
117
|
+
.filter((r) => !same(r.from, r.to));
|
|
118
|
+
for (const r of redirectsAddedBy(moves, homes)) next.push(r);
|
|
119
|
+
return next;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The redirects a plan adds (`redirectsAfterMoves`): one per move with `add`, unless another document now sits at its old address, and one per stranded live address (`homes`). */
|
|
123
|
+
export function redirectsAddedBy(moves: readonly { from: string; to: string; add: boolean }[], homes: readonly LiveHome[] = []): Redirect[] {
|
|
124
|
+
const taken = (address: string) => moves.some((m) => same(m.to, address));
|
|
125
|
+
const added = moves.filter((m) => m.add && !same(m.from, m.to) && !taken(m.from)).map((m) => ({ from: m.from, to: m.to }));
|
|
126
|
+
for (const r of strandedHomes(moves, homes)) if (!added.some((a) => same(a.from, r.from))) added.push(r);
|
|
127
|
+
return added;
|
|
128
|
+
}
|
|
129
|
+
|
|
67
130
|
/** Where a request for `pathname` goes, or null. Matched without a trailing slash; letter case counts, as it does for pages. */
|
|
68
131
|
export function redirectFor(list: readonly Redirect[], pathname: string): string | null {
|
|
69
132
|
let path: string;
|
package/src/relink.ts
CHANGED
|
@@ -145,17 +145,47 @@ function fieldAt(site: Pick<ResolvedSite, 'blocks'>, collection: Pick<ResolvedCo
|
|
|
145
145
|
return field ? { field, rest } : null;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
/**
|
|
149
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Several moves at once (collections under pages, Decision 9: a page and everything under
|
|
150
|
+
* it): each link is matched against the addresses as they were, once, so a swap stays a
|
|
151
|
+
* swap and a link is never moved twice.
|
|
152
|
+
*/
|
|
153
|
+
interface MoveIndex {
|
|
154
|
+
href: HrefRewrite;
|
|
155
|
+
ref: (collection: string, path: string) => string | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function indexMoves(moves: readonly AddressMove[]): MoveIndex {
|
|
159
|
+
const byUrl = new Map<string, AddressMove>();
|
|
160
|
+
const byPath = new Map<string, AddressMove>();
|
|
161
|
+
for (const m of moves) {
|
|
162
|
+
const url = normaliseAddress(m.fromUrl);
|
|
163
|
+
if (m.fromUrl !== m.toUrl && !byUrl.has(url)) byUrl.set(url, m);
|
|
164
|
+
// A held document keeps its path when its holder moves (check 18: references to it never change).
|
|
165
|
+
const key = `${m.collection}\u0000${m.fromPath}`;
|
|
166
|
+
if (m.fromPath !== m.toPath && !byPath.has(key)) byPath.set(key, m);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
href: (href) => {
|
|
170
|
+
const path = internalLinkPath(href);
|
|
171
|
+
const move = path === null ? undefined : byUrl.get(path);
|
|
172
|
+
return move ? relinkHref(href, move) : null;
|
|
173
|
+
},
|
|
174
|
+
ref: (collection, path) => byPath.get(`${collection}\u0000${path}`)?.toPath ?? null,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A string at `rest` inside a field's value, with its links to the old addresses rewritten; null when it isn't a link there or doesn't point at one. */
|
|
179
|
+
function relinkIn(field: Field, rest: Segment[], value: string, moves: MoveIndex): string | null {
|
|
150
180
|
switch (field.kind) {
|
|
151
181
|
case 'link':
|
|
152
|
-
return rest.length === 1 && rest[0] === 'href' ?
|
|
182
|
+
return rest.length === 1 && rest[0] === 'href' ? moves.href(value) : null;
|
|
153
183
|
case 'buttons':
|
|
154
|
-
return rest.length === 2 && typeof rest[0] === 'number' && rest[1] === 'href' ?
|
|
184
|
+
return rest.length === 2 && typeof rest[0] === 'number' && rest[1] === 'href' ? moves.href(value) : null;
|
|
155
185
|
case 'richText':
|
|
156
|
-
return
|
|
186
|
+
return rewriteRichText(value, moves.href)?.text ?? null;
|
|
157
187
|
case 'reference':
|
|
158
|
-
return rest.length === 0 && field.to
|
|
188
|
+
return rest.length === 0 && field.to ? moves.ref(field.to, value.trim()) : null;
|
|
159
189
|
default:
|
|
160
190
|
return null;
|
|
161
191
|
}
|
|
@@ -217,17 +247,18 @@ function splice(text: string, edits: { start: number; end: number; text: string
|
|
|
217
247
|
/**
|
|
218
248
|
* The file's text with every link and reference to the moved document pointing at
|
|
219
249
|
* its new address (or path); `text` itself when nothing points there. `collection` is
|
|
220
|
-
* the file's own collection (for its schema).
|
|
250
|
+
* the file's own collection (for its schema). Several moves are applied at once.
|
|
221
251
|
*/
|
|
222
252
|
export function relinkText(
|
|
223
253
|
text: string,
|
|
224
|
-
move: AddressMove,
|
|
254
|
+
move: AddressMove | readonly AddressMove[],
|
|
225
255
|
site: Pick<ResolvedSite, 'blocks'>,
|
|
226
256
|
collection: Pick<ResolvedCollection, 'kind' | 'fields'>,
|
|
227
257
|
): { text: string; links: number } {
|
|
228
258
|
const edits: { start: number; end: number; text: string }[] = [];
|
|
259
|
+
const moves = indexMoves(Array.isArray(move) ? move : [move as AddressMove]);
|
|
229
260
|
const ok = fieldStrings(text, site, collection, (field, rest, value, node) => {
|
|
230
|
-
const next = relinkIn(field, rest, value,
|
|
261
|
+
const next = relinkIn(field, rest, value, moves);
|
|
231
262
|
if (next !== null && next !== value) edits.push({ start: node.start, end: node.end, text: JSON.stringify(next) });
|
|
232
263
|
});
|
|
233
264
|
if (!ok || edits.length === 0) return { text, links: 0 };
|
package/src/reserved.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const RESERVED_COLLECTIONS = ['shared', 'settings'] as const;
|
|
|
19
19
|
* The keys of Site Settings the package owns (Decision 10); the site's own
|
|
20
20
|
* `settings.fields` can't use them. M6 adds the search and sharing defaults, the
|
|
21
21
|
* Google tag and the form emails' recipients (M6 Decision 14); pre-M7 item 8, the
|
|
22
|
-
* redirects (redirects.ts)
|
|
22
|
+
* redirects (redirects.ts); arrange pages (Decision 6), the Pages tree's saved order
|
|
23
|
+
* (page-order.ts), which only the arrange screen writes.
|
|
23
24
|
*/
|
|
24
|
-
export const SETTINGS_KEYS = ['title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects'] as const;
|
|
25
|
+
export const SETTINGS_KEYS = ['title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects', 'pageOrder'] as const;
|
package/src/serialize.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
import { fieldToJson, isEmptyValue, parseField, type Field, type Fields } from './fields.ts';
|
|
58
58
|
import { mediaLine } from './media-value.ts';
|
|
59
59
|
import { parseRedirects } from './redirects.ts';
|
|
60
|
+
import { parsePageOrder } from './page-order.ts';
|
|
60
61
|
import { member, parseSource, slice, type ArrayNode, type JsonNode, type MemberNode, type ObjectNode, type ParsedSource } from './json-source.ts';
|
|
61
62
|
import { isEmptySeo, parseFormRecipients, parseGoogleTag, parseSeo, seoText, type Seo } from './seo.ts';
|
|
62
63
|
import { SCHEMA_KEY } from './schema-key.ts';
|
|
@@ -242,7 +243,7 @@ export function applyEdits(base: string | null, edited: SiteDocument, site: Pick
|
|
|
242
243
|
}
|
|
243
244
|
if (fieldTexts.size) {
|
|
244
245
|
const next = parseSource(out);
|
|
245
|
-
if (next.root.kind === 'object') out = spliceKeys(next, next.root, fieldTexts, ['title', 'hidden', ...Object.keys(fields)]);
|
|
246
|
+
if (next.root.kind === 'object') out = spliceKeys(next, next.root, fieldTexts, ['title', 'hidden', 'holds', ...Object.keys(fields)]);
|
|
246
247
|
}
|
|
247
248
|
return withTrailingNewline(out);
|
|
248
249
|
}
|
|
@@ -384,6 +385,18 @@ const redirectsCodec: KeyCodec = {
|
|
|
384
385
|
removeWhenEmpty: true,
|
|
385
386
|
};
|
|
386
387
|
|
|
388
|
+
/** Arrange pages (Decision 6): the saved order, one parent's list per line. */
|
|
389
|
+
const pageOrderCodec: KeyCodec = {
|
|
390
|
+
parse: parsePageOrder,
|
|
391
|
+
text: (v, depth) => {
|
|
392
|
+
const entries = Object.entries(parsePageOrder(v));
|
|
393
|
+
if (entries.length === 0) return '{}';
|
|
394
|
+
return `{\n${entries.map(([k, list]) => `${pad(depth + 1)}${JSON.stringify(k)}: [${list.map((s) => JSON.stringify(s)).join(', ')}]`).join(',\n')}\n${pad(depth)}}`;
|
|
395
|
+
},
|
|
396
|
+
empty: (v) => Object.keys(parsePageOrder(v)).length === 0,
|
|
397
|
+
removeWhenEmpty: true,
|
|
398
|
+
};
|
|
399
|
+
|
|
387
400
|
const fieldCodec = (field: Field): KeyCodec => ({
|
|
388
401
|
parse: (raw) => parseField(field, raw),
|
|
389
402
|
text: (value, depth) => fieldText(field, value, depth),
|
|
@@ -419,6 +432,7 @@ function codecsFor(collection: Pick<ResolvedCollection, 'kind' | 'fields'>): Map
|
|
|
419
432
|
codecs.set('googleTag', googleTagCodec);
|
|
420
433
|
codecs.set('formRecipients', recipientsCodec);
|
|
421
434
|
codecs.set('redirects', redirectsCodec);
|
|
435
|
+
codecs.set('pageOrder', pageOrderCodec);
|
|
422
436
|
}
|
|
423
437
|
for (const [key, field] of Object.entries(collection.fields ?? ({} as Fields))) codecs.set(key, fieldCodec(field));
|
|
424
438
|
return codecs;
|
|
@@ -692,6 +706,9 @@ export function savedForm(text: string, collection: Pick<ResolvedCollection, 'ki
|
|
|
692
706
|
// brandCodec: logo and icon on one line each; redirectsCodec: one redirect per line.
|
|
693
707
|
keys.set('brand', { kind: 'object', keys: new Map([['logo', INLINE], ['icon', INLINE]]) });
|
|
694
708
|
keys.set('redirects', { kind: 'list', item: INLINE });
|
|
709
|
+
// pageOrderCodec: one parent's list per line.
|
|
710
|
+
const order = (raw as Record<string, unknown>).pageOrder;
|
|
711
|
+
if (order && typeof order === 'object' && !Array.isArray(order)) keys.set('pageOrder', { kind: 'object', keys: new Map(Object.keys(order).map((k) => [k, INLINE])) });
|
|
695
712
|
}
|
|
696
713
|
return `${layout(raw, { kind: 'object', keys }, 0)}\n`;
|
|
697
714
|
}
|
package/src/slug.ts
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* or null: under the editor's own `/admin` or `/_caracms`, or the library's files'
|
|
18
18
|
* `/files` (M6c Decision 3, served by files-route.ts); strictly below another
|
|
19
19
|
* collection's non-root `urlBase` (exactly at it is allowed: the Work page at
|
|
20
|
-
* `/work`)
|
|
20
|
+
* `/work`), unless a page holds that collection (collections under pages, Decision 6:
|
|
21
|
+
* pages may sit under a holder beside its documents); or another document's address
|
|
22
|
+
* already, in any collection, held documents included (Decision 8), naming it.
|
|
21
23
|
*/
|
|
22
24
|
import { codeRouteAt, codeRouteMessage, type CodeRoute } from './code-routes.ts';
|
|
23
25
|
|
|
@@ -56,8 +58,10 @@ export const INDEX_MESSAGE = '"index" is kept for Home, so it can\'t be an addre
|
|
|
56
58
|
export interface AddressCollection {
|
|
57
59
|
name: string;
|
|
58
60
|
label: string;
|
|
59
|
-
/** Starts and ends with `/`; null for collections with no pages (shared blocks, Site Settings). */
|
|
61
|
+
/** Starts and ends with `/`; null for collections with no pages (shared blocks, Site Settings). A held collection's is its resolved base (held.ts). */
|
|
60
62
|
urlBase: string | null;
|
|
63
|
+
/** Collections under pages: set on a collection a page holds, whose base doesn't refuse pages below it (Decision 6). */
|
|
64
|
+
heldBy?: string | null;
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
/** What `addressProblem` needs of a document. */
|
|
@@ -65,6 +69,8 @@ export interface AddressDocument {
|
|
|
65
69
|
collection: string;
|
|
66
70
|
path: string;
|
|
67
71
|
url: string | null;
|
|
72
|
+
/** What the editor lists it as: `address-taken` names a document of another collection by it (Decision 8). */
|
|
73
|
+
label?: string;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
export type AddressProblemCode = 'reserved-address' | 'below-collection' | 'address-taken';
|
|
@@ -105,7 +111,7 @@ export function addressProblem(
|
|
|
105
111
|
const route = codeRouteAt(url, codeRoutes);
|
|
106
112
|
if (route) return { code: 'reserved-address', message: codeRouteMessage(url, route) };
|
|
107
113
|
for (const other of collections) {
|
|
108
|
-
if (other.name === collection || other.urlBase === null) continue;
|
|
114
|
+
if (other.name === collection || other.urlBase === null || other.heldBy) continue;
|
|
109
115
|
const base = sameAddress(other.urlBase);
|
|
110
116
|
if (base === '/') continue;
|
|
111
117
|
if (at.startsWith(`${base}/`)) {
|
|
@@ -116,6 +122,11 @@ export function addressProblem(
|
|
|
116
122
|
}
|
|
117
123
|
}
|
|
118
124
|
const taken = documents.find((d) => d.url !== null && sameAddress(d.url) === at);
|
|
119
|
-
if (taken)
|
|
125
|
+
if (taken) {
|
|
126
|
+
// Decision 8: a held document and a page can't share an address either way; the other is named.
|
|
127
|
+
const other = taken.collection !== collection ? collections.find((c) => c.name === taken.collection) : undefined;
|
|
128
|
+
const named = other && taken.label ? `: “${taken.label}” in ${other.label}` : '';
|
|
129
|
+
return { code: 'address-taken', message: `There's already a page at ${url}${named}.` };
|
|
130
|
+
}
|
|
120
131
|
return null;
|
|
121
132
|
}
|