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.
Files changed (66) hide show
  1. package/README.md +73 -3
  2. package/bin/templates/catch-all.astro.txt +10 -9
  3. package/package.json +1 -1
  4. package/skills/caracms-adopt/SKILL.md +21 -4
  5. package/skills/caracms-blocks/SKILL.md +36 -13
  6. package/skills/caracms-content/SKILL.md +27 -6
  7. package/skills/caracms-tina/SKILL.md +53 -11
  8. package/skills/caracms-tina/field-kinds.md +2 -0
  9. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  10. package/src/cli/validate.ts +103 -12
  11. package/src/code-routes.ts +26 -0
  12. package/src/components/Head.astro +5 -5
  13. package/src/components/RenderDocument.astro +3 -3
  14. package/src/components/sitemap.xml.ts +5 -4
  15. package/src/config.ts +57 -11
  16. package/src/content-schema.ts +6 -2
  17. package/src/content.ts +188 -27
  18. package/src/document.ts +13 -2
  19. package/src/editor/README.md +3 -0
  20. package/src/editor/addresses.ts +6 -1
  21. package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
  22. package/src/editor/client/arrange/model.ts +659 -0
  23. package/src/editor/client/arrange/store.ts +335 -0
  24. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  25. package/src/editor/client/documents/actions.ts +75 -19
  26. package/src/editor/client/documents/dialogs.tsx +137 -18
  27. package/src/editor/client/editor.ts +78 -6
  28. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  29. package/src/editor/client/nav/DocRow.tsx +8 -1
  30. package/src/editor/client/nav/tree.ts +26 -1
  31. package/src/editor/client/pane/Pane.tsx +3 -0
  32. package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
  33. package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
  34. package/src/editor/client/pane/parts.tsx +14 -1
  35. package/src/editor/client/shell/Stage.tsx +8 -4
  36. package/src/editor/routes/admin.astro +1 -1
  37. package/src/editor/routes/arrange.ts +17 -0
  38. package/src/editor/routes/move.ts +1 -1
  39. package/src/editor/server/admin-props.ts +24 -4
  40. package/src/editor/server/arrange.ts +245 -0
  41. package/src/editor/server/create.ts +13 -2
  42. package/src/editor/server/drafts-store.ts +55 -10
  43. package/src/editor/server/move.ts +300 -81
  44. package/src/editor/styles/editor.css +5 -0
  45. package/src/editor/styles/levels/arrange.css +152 -0
  46. package/src/editor/styles/levels/collections.css +2 -0
  47. package/src/editor/styles/levels/m5a-documents.css +15 -0
  48. package/src/editor/types.ts +83 -7
  49. package/src/held.ts +116 -0
  50. package/src/index.ts +6 -2
  51. package/src/live/README.md +3 -2
  52. package/src/live/auth/middleware.ts +11 -0
  53. package/src/live/autosaves.ts +88 -1
  54. package/src/live/cloud.ts +131 -0
  55. package/src/live/drafts-store.ts +170 -77
  56. package/src/live/editor.ts +26 -4
  57. package/src/live/git.ts +63 -12
  58. package/src/live/github.ts +69 -7
  59. package/src/live/mode.ts +42 -6
  60. package/src/live/runtime.ts +18 -2
  61. package/src/page-order.ts +253 -0
  62. package/src/redirects.ts +64 -1
  63. package/src/relink.ts +40 -9
  64. package/src/reserved.ts +3 -2
  65. package/src/serialize.ts +18 -1
  66. 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
- /** A string at `rest` inside a field's value, with its links to the old address rewritten; null when it isn't a link there or doesn't point there. */
149
- function relinkIn(field: Field, rest: Segment[], value: string, move: AddressMove): string | null {
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' ? relinkHref(value, move) : null;
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' ? relinkHref(value, move) : null;
184
+ return rest.length === 2 && typeof rest[0] === 'number' && rest[1] === 'href' ? moves.href(value) : null;
155
185
  case 'richText':
156
- return relinkRichText(value, move);
186
+ return rewriteRichText(value, moves.href)?.text ?? null;
157
187
  case 'reference':
158
- return rest.length === 0 && field.to === move.collection && value.trim() === move.fromPath ? move.toPath : null;
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, move);
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`); or another document's address already, in any collection.
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) return { code: 'address-taken', message: `There's already a page at ${url}.` };
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
  }