caracms 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +59 -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 +51 -10
  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 +5 -2
  17. package/src/content.ts +148 -8
  18. package/src/document.ts +8 -0
  19. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  20. package/src/editor/client/documents/actions.ts +61 -16
  21. package/src/editor/client/documents/dialogs.tsx +104 -15
  22. package/src/editor/client/editor.ts +22 -1
  23. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  24. package/src/editor/client/nav/DocRow.tsx +8 -1
  25. package/src/editor/client/nav/tree.ts +26 -1
  26. package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
  27. package/src/editor/client/pane/parts.tsx +14 -1
  28. package/src/editor/routes/move.ts +1 -1
  29. package/src/editor/server/admin-props.ts +19 -1
  30. package/src/editor/server/create.ts +13 -2
  31. package/src/editor/server/drafts-store.ts +24 -9
  32. package/src/editor/server/move.ts +266 -81
  33. package/src/editor/styles/editor.css +5 -0
  34. package/src/editor/styles/levels/collections.css +2 -0
  35. package/src/editor/styles/levels/m5a-documents.css +15 -0
  36. package/src/editor/types.ts +31 -5
  37. package/src/held.ts +116 -0
  38. package/src/index.ts +4 -2
  39. package/src/live/README.md +3 -2
  40. package/src/live/auth/middleware.ts +11 -0
  41. package/src/live/autosaves.ts +88 -1
  42. package/src/live/cloud.ts +131 -0
  43. package/src/live/drafts-store.ts +122 -87
  44. package/src/live/editor.ts +22 -4
  45. package/src/live/git.ts +63 -12
  46. package/src/live/github.ts +69 -7
  47. package/src/live/mode.ts +42 -6
  48. package/src/live/runtime.ts +18 -2
  49. package/src/redirects.ts +32 -1
  50. package/src/relink.ts +40 -9
  51. package/src/serialize.ts +1 -1
  52. package/src/slug.ts +15 -4
package/src/live/mode.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Which mode the editor runs in, read from the environment (M4 Decision 1).
3
3
  *
4
- * - **Local mode**: `astro dev` with no GitHub credentials (`GITHUB_APP_ID` or
5
- * `CARACMS_GITHUB_TOKEN`). M3's editor, files on disk.
4
+ * - **Local mode**: `astro dev` with no GitHub credentials (`CARACMS_CLOUD_KEY`,
5
+ * `GITHUB_APP_ID` or `CARACMS_GITHUB_TOKEN`). M3's editor, files on disk.
6
6
  * - **Live mode**: whenever a credential is configured, and **always on a production
7
7
  * server**. It needs one GitHub credential (a fine-grained token, or the App's
8
8
  * `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`; runs-anywhere Decisions 3–4), the repo
9
9
  * (`GITHUB_REPO=owner/name`, or Railway's `RAILWAY_GIT_REPO_OWNER` and
10
- * `RAILWAY_GIT_REPO_NAME`; Decision 6), `CARACMS_SECRET`, `REDIS_URL` and the site's
10
+ * `RAILWAY_GIT_REPO_NAME`; Decision 6), or CaraCMS Cloud's site key instead of both
11
+ * (`CARACMS_CLOUD_KEY`, with `CARACMS_CLOUD_URL` for another deployment than
12
+ * production; caracms-cloud M2 Decision 11: the repo comes from the cloud at start), `CARACMS_SECRET`, `REDIS_URL` and the site's
11
13
  * `https://` address in production (dev may use the stand-in Redis). A production
12
14
  * server missing any of them still serves the public site; `/admin` answers 503
13
15
  * naming them (Phase 2 builds that from `missing`).
@@ -18,6 +20,7 @@
18
20
  * This only reads; it doesn't wire anything (Phase 2 switches the routes on it,
19
21
  * Phase 3 guards with it).
20
22
  */
23
+ import { CLOUD_KEY_SHAPE, DEFAULT_CLOUD_URL } from './cloud.ts';
21
24
  import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
22
25
  import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
23
26
 
@@ -26,6 +29,8 @@ export interface LiveSettings {
26
29
  github:
27
30
  | { kind: 'app'; appId: string; privateKey: string; owner: string; repo: string }
28
31
  | { kind: 'token'; token: string; owner: string; repo: string }
32
+ /** CARACMS_CLOUD_KEY (caracms-cloud M2 Decision 11): the repo comes from the cloud when the runtime starts. */
33
+ | { kind: 'cloud'; key: string; url: string }
29
34
  | { kind: 'memory' };
30
35
  /** Null in dev without REDIS_URL: the in-memory stand-in. */
31
36
  redisUrl: string | null;
@@ -88,6 +93,13 @@ export const NO_ADDRESS_PROBLEM =
88
93
  "The site's public address isn't known: set SITE_URL to it, like https://fernhill.ie, both when the site is built and when it runs. Without it, sign-in links can't be sent and the editor's saves are refused.";
89
94
  export const HTTP_ADDRESS_PROBLEM = (address: string) =>
90
95
  `The site's address (${address}) is http://, but signing in needs https://: the session cookie is only ever sent over https. Set SITE_URL to the https:// address (http:// works for localhost only).`;
96
+ export const CLOUD_KEY_PROBLEM =
97
+ "CARACMS_CLOUD_KEY isn't a CaraCMS Cloud site key: a key is cck_ followed by 43 letters, digits, - and _. Copy it again from app.caracms.com.";
98
+ export const CLOUD_AND_GITHUB_PROBLEM = (other: string) =>
99
+ `Both CARACMS_CLOUD_KEY and ${other} are set: a site uses one GitHub credential, so remove ${other}.`;
100
+ export const CLOUD_REPO_PROBLEM = 'CARACMS_CLOUD_KEY is set, so the repo comes from CaraCMS Cloud; remove GITHUB_REPO.';
101
+ export const CLOUD_URL_PROBLEM = (url: string) =>
102
+ `CARACMS_CLOUD_URL must be the cloud's https:// address (http:// works for localhost only; got "${url}"). Leave it unset for CaraCMS Cloud itself.`;
91
103
  export const BOTH_REDEPLOYERS_PROBLEM =
92
104
  'Both CARACMS_DEPLOY_HOOK_URL and CARACMS_RAILWAY_TOKEN are set: Try again redeploys through one of them, so set only one.';
93
105
 
@@ -153,7 +165,8 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
153
165
  const memory = get('CARACMS_GITHUB') === 'memory';
154
166
  // Never GITHUB_TOKEN (Decision 4): the gh CLI and GitHub Actions export it.
155
167
  const ghToken = get('CARACMS_GITHUB_TOKEN');
156
- if (!production && !memory && !get('GITHUB_APP_ID') && !ghToken) return { kind: 'local' };
168
+ const cloudKey = get('CARACMS_CLOUD_KEY');
169
+ if (!production && !memory && !get('GITHUB_APP_ID') && !ghToken && !cloudKey) return { kind: 'local' };
157
170
 
158
171
  const missing: string[] = [];
159
172
  const problems: string[] = [];
@@ -168,7 +181,14 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
168
181
  if (other && other !== 'memory') problems.push(`CARACMS_GITHUB must be "memory" or unset (got "${other}").`);
169
182
 
170
183
  let github: LiveSettings['github'] = { kind: 'memory' };
171
- if (!memory || production) {
184
+ if (cloudKey && (!memory || production)) {
185
+ // The cloud names the repo (Decision 13), so GITHUB_REPO is a mistake and Railway's own variables are ignored.
186
+ if (!CLOUD_KEY_SHAPE.test(cloudKey)) problems.push(CLOUD_KEY_PROBLEM);
187
+ if (ghToken) problems.push(CLOUD_AND_GITHUB_PROBLEM('CARACMS_GITHUB_TOKEN'));
188
+ if (get('GITHUB_APP_ID')) problems.push(CLOUD_AND_GITHUB_PROBLEM('GITHUB_APP_ID'));
189
+ if (get('GITHUB_REPO')) problems.push(CLOUD_REPO_PROBLEM);
190
+ github = { kind: 'cloud', key: cloudKey, url: readCloudUrl(get('CARACMS_CLOUD_URL'), problems) };
191
+ } else if (!memory || production) {
172
192
  const appId = get('GITHUB_APP_ID');
173
193
  const repo = readRepo(env, problems);
174
194
  const owner = repo?.owner ?? '';
@@ -181,7 +201,7 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
181
201
  } else if (appId) {
182
202
  github = { kind: 'app', appId, privateKey: need('GITHUB_APP_PRIVATE_KEY'), owner, repo: name };
183
203
  } else {
184
- missing.push('CARACMS_GITHUB_TOKEN (or GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY)');
204
+ missing.push('CARACMS_CLOUD_KEY (or CARACMS_GITHUB_TOKEN, or GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY)');
185
205
  }
186
206
  if (!repo && !problems.some((p) => p.startsWith('GITHUB_REPO'))) {
187
207
  missing.push(onRailway(env) ? 'RAILWAY_GIT_REPO_OWNER and RAILWAY_GIT_REPO_NAME (or GITHUB_REPO)' : 'GITHUB_REPO');
@@ -277,6 +297,22 @@ function isBranchName(name: string): boolean {
277
297
  );
278
298
  }
279
299
 
300
+ /** CARACMS_CLOUD_URL, without a trailing slash: https://, or http:// on localhost. Unset, the production address. */
301
+ function readCloudUrl(raw: string | null, problems: string[]): string {
302
+ if (!raw) return DEFAULT_CLOUD_URL;
303
+ let url: URL | null = null;
304
+ try {
305
+ url = new URL(raw);
306
+ } catch {
307
+ /* a problem, below */
308
+ }
309
+ if (!url || !(url.protocol === 'https:' || (url.protocol === 'http:' && isLocalhost(url.hostname)))) {
310
+ problems.push(CLOUD_URL_PROBLEM(raw));
311
+ return raw;
312
+ }
313
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, '');
314
+ }
315
+
280
316
  /** The repo: GITHUB_REPO overrides Railway's own variables (M4 Decision 3; runs-anywhere Decision 6). */
281
317
  function readRepo(env: Env, problems: string[]): { owner: string; repo: string } | null {
282
318
  const override = env.GITHUB_REPO?.trim();
@@ -20,6 +20,7 @@ import { join, relative, sep } from 'node:path';
20
20
  import { repoPrefix, type ResolvedSite } from '../config.ts';
21
21
  import { GitHubBackend, MemoryBackend, type FileWrite, type GitBackend } from './backend.ts';
22
22
  import { DEFAULT_BRANCHES } from './branches.ts';
23
+ import { cloudSettings } from './cloud.ts';
23
24
  import { GitHubClient, Tracker } from './github.ts';
24
25
  import { LiveGit, SITE_SETTINGS_FILE } from './git.ts';
25
26
  import type { LiveSettings } from './mode.ts';
@@ -52,6 +53,8 @@ export interface RuntimeOptions {
52
53
  railwayFetch?: typeof fetch;
53
54
  /** Tests: the deploy hook's fetch. */
54
55
  deployHookFetch?: typeof fetch;
56
+ /** Tests: the fetch GitHub and CaraCMS Cloud are reached through. */
57
+ fetch?: typeof fetch;
55
58
  }
56
59
 
57
60
  /** Build and start a runtime (not shared: tests make their own). */
@@ -72,8 +75,21 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
72
75
  } else {
73
76
  const tracker = new Tracker(now);
74
77
  const gh = settings.github;
75
- const connection = gh.kind === 'token' ? { token: gh.token, owner: gh.owner, repo: gh.repo, now } : { appId: gh.appId, privateKey: gh.privateKey, owner: gh.owner, repo: gh.repo, now };
76
- backend = new GitHubBackend(new GitHubClient(connection, tracker), settings.branches);
78
+ const fetch = options.fetch;
79
+ let client: GitHubClient;
80
+ if (gh.kind === 'cloud') {
81
+ // caracms-cloud M2 Decision 13: the repo from the cloud's settings, then the first
82
+ // token, so a refusal of either stops the start (the sign-in guard shows its sentence).
83
+ const { owner, repo } = await cloudSettings({ key: gh.key, url: gh.url, fetch });
84
+ client = new GitHubClient({ cloudKey: gh.key, cloudUrl: gh.url, owner, repo, now, fetch }, tracker);
85
+ await client.ready();
86
+ log(`live mode on ${owner}/${repo} through CaraCMS Cloud`);
87
+ } else {
88
+ const connection =
89
+ gh.kind === 'token' ? { token: gh.token, owner: gh.owner, repo: gh.repo, now, fetch } : { appId: gh.appId, privateKey: gh.privateKey, owner: gh.owner, repo: gh.repo, now, fetch };
90
+ client = new GitHubClient(connection, tracker);
91
+ }
92
+ backend = new GitHubBackend(client, settings.branches);
77
93
  }
78
94
  }
79
95
  const { main, drafts } = backend.branches;
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) 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,36 @@ export function redirectsAfterMove(list: readonly Redirect[], from: string, to:
64
65
  return next;
65
66
  }
66
67
 
68
+ /**
69
+ * The list once several documents move at once (the many-move engine): every move is
70
+ * taken against the list before the plan, so a swap or a chain comes out as its
71
+ * documents end up. Entries to an old address go to that document's new one (chains
72
+ * collapse, one step: `X → A` becomes `X → B` when A moves to B); an entry from an
73
+ * address a document now occupies goes (a page is there); and, per move with `add`,
74
+ * `from → to` is added, unless another document now sits at `from` (in a swap neither
75
+ * is added). With no address both vacated and taken, this is `redirectsAfterMove` per
76
+ * move, in order.
77
+ */
78
+ export function redirectsAfterMoves(list: readonly Redirect[], moves: readonly { from: string; to: string; add: boolean }[]): Redirect[] {
79
+ const taken = (address: string) => moves.some((m) => same(m.to, address));
80
+ const replaced = (address: string) => moves.some((m) => m.add && same(m.from, address));
81
+ const next = list
82
+ .filter((r) => !taken(r.from) && !replaced(r.from))
83
+ .map((r) => {
84
+ const m = moves.find((m) => same(r.to, m.from));
85
+ return m ? { from: r.from, to: m.to } : r;
86
+ })
87
+ .filter((r) => !same(r.from, r.to));
88
+ for (const m of moves) if (m.add && !same(m.from, m.to) && !taken(m.from)) next.push({ from: m.from, to: m.to });
89
+ return next;
90
+ }
91
+
92
+ /** The redirects a plan adds (`redirectsAfterMoves`): one per move with `add`, unless another document now sits at its old address. */
93
+ export function redirectsAddedBy(moves: readonly { from: string; to: string; add: boolean }[]): Redirect[] {
94
+ const taken = (address: string) => moves.some((m) => same(m.to, address));
95
+ return moves.filter((m) => m.add && !same(m.from, m.to) && !taken(m.from)).map((m) => ({ from: m.from, to: m.to }));
96
+ }
97
+
67
98
  /** Where a request for `pathname` goes, or null. Matched without a trailing slash; letter case counts, as it does for pages. */
68
99
  export function redirectFor(list: readonly Redirect[], pathname: string): string | null {
69
100
  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/serialize.ts CHANGED
@@ -242,7 +242,7 @@ export function applyEdits(base: string | null, edited: SiteDocument, site: Pick
242
242
  }
243
243
  if (fieldTexts.size) {
244
244
  const next = parseSource(out);
245
- if (next.root.kind === 'object') out = spliceKeys(next, next.root, fieldTexts, ['title', 'hidden', ...Object.keys(fields)]);
245
+ if (next.root.kind === 'object') out = spliceKeys(next, next.root, fieldTexts, ['title', 'hidden', 'holds', ...Object.keys(fields)]);
246
246
  }
247
247
  return withTrailingNewline(out);
248
248
  }
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
  }