caracms 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -3
- package/bin/caracms.mjs +30 -6
- package/bin/gate.mjs +157 -6
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +51 -10
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +5 -2
- package/src/content.ts +148 -8
- package/src/document.ts +8 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +61 -16
- package/src/editor/client/documents/dialogs.tsx +104 -15
- package/src/editor/client/editor.ts +22 -1
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +19 -1
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +24 -9
- package/src/editor/server/move.ts +266 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +31 -5
- package/src/held.ts +116 -0
- package/src/index.ts +4 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +122 -87
- package/src/live/editor.ts +22 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/redirect-middleware.ts +3 -0
- package/src/redirects.ts +32 -1
- package/src/relink.ts +40 -9
- package/src/serialize.ts +1 -1
- 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 (`
|
|
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),
|
|
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
|
-
|
|
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('
|
|
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();
|
package/src/live/runtime.ts
CHANGED
|
@@ -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
|
|
76
|
-
|
|
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;
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* goes out with the Publish that moves the page. Re-read when the file changes
|
|
11
11
|
* (its modification time), so local edits apply at once.
|
|
12
12
|
* - The editor's own paths (`/admin`, `/_caracms/`) are never redirected.
|
|
13
|
+
*
|
|
14
|
+
* This serves them in dev. A built site's 404 page is prerendered, sent without this
|
|
15
|
+
* middleware running, so `caracms start`'s gate (bin/gate.mjs) serves them there.
|
|
13
16
|
*/
|
|
14
17
|
import { readFile, stat } from 'node:fs/promises';
|
|
15
18
|
import { join } from 'node:path';
|
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
|
-
/**
|
|
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/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`)
|
|
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
|
}
|