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/held.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collections under pages (Decisions 2, 7, 11, 13): a held collection's address is the
|
|
3
|
+
* address of the page that holds it, worked out from a listing, never from the config.
|
|
4
|
+
*
|
|
5
|
+
* - **The mount is in the content:** a page of the collection `heldBy` names says
|
|
6
|
+
* `"holds": "<collection>"` (content.ts reads it into the entry's `holds`). The
|
|
7
|
+
* collection's base is that page's address + `/` (`/journal/`), so its documents follow
|
|
8
|
+
* the page wherever it moves.
|
|
9
|
+
* - **One holder or none.** No page holding it, two pages holding it, Home holding it, or
|
|
10
|
+
* the holder's own file unopenable (invalid JSON, no title): the collection has no
|
|
11
|
+
* addresses (base null) and `problem` says why, naming the files. `validate` reports the
|
|
12
|
+
* same sentence as an error; the editor shows it on the collection's sidebar entry.
|
|
13
|
+
* - **A hidden holder hides its documents** (Decision 11), as a hidden page hides the pages
|
|
14
|
+
* under it: `hidden` here, and content.ts marks each document.
|
|
15
|
+
* - **Pure and browser-safe** (no imports): the server resolves bases for the editor's
|
|
16
|
+
* payload, and the browser can do the same from a fresh listing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What resolving needs of a collection (config.ts → ResolvedCollection, the editor's payload). */
|
|
20
|
+
export interface HoldableCollection {
|
|
21
|
+
name: string;
|
|
22
|
+
label: string;
|
|
23
|
+
urlBase: string | null;
|
|
24
|
+
heldBy?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What resolving needs of a listed document (content.ts → DocumentEntry). */
|
|
28
|
+
export interface HoldingEntry {
|
|
29
|
+
collection: string;
|
|
30
|
+
path: string;
|
|
31
|
+
file: string;
|
|
32
|
+
url: string | null;
|
|
33
|
+
label: string;
|
|
34
|
+
warning: { message: string } | null;
|
|
35
|
+
hidden?: true;
|
|
36
|
+
/** The collection key the page's file says it holds (`"holds"`), even when the file can't be opened. */
|
|
37
|
+
holds?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HeldBase {
|
|
41
|
+
collection: string;
|
|
42
|
+
/** The holder's address + `/` (`/journal/`); null while no page holds it, or the holding is a problem. */
|
|
43
|
+
base: string | null;
|
|
44
|
+
/** The page that holds it; null when none does (or two do). */
|
|
45
|
+
holder: { collection: string; path: string; file: string; label: string } | null;
|
|
46
|
+
/** The holder is hidden from the site (itself, or a page above it), so the documents are too (Decision 11). */
|
|
47
|
+
hidden: boolean;
|
|
48
|
+
/** Decision 13's sentence when the collection has no addresses, naming the files; null when it's held. */
|
|
49
|
+
problem: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const and = (items: readonly string[]): string => (items.length <= 1 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
|
|
53
|
+
|
|
54
|
+
/** Every held collection's base, from a listing that has (at least) every document of the collections their `heldBy` names. */
|
|
55
|
+
export function heldBases(collections: Readonly<Record<string, HoldableCollection>>, entries: readonly HoldingEntry[]): Record<string, HeldBase> {
|
|
56
|
+
const out: Record<string, HeldBase> = {};
|
|
57
|
+
for (const collection of Object.values(collections)) {
|
|
58
|
+
if (!collection.heldBy) continue;
|
|
59
|
+
const { name, label } = collection;
|
|
60
|
+
const holders = entries.filter((e) => e.collection === collection.heldBy && e.holds === name);
|
|
61
|
+
const none: HeldBase = { collection: name, base: null, holder: null, hidden: false, problem: null };
|
|
62
|
+
if (holders.length === 0) {
|
|
63
|
+
out[name] = { ...none, problem: `No page holds ${label}, so its documents have no addresses and aren't on the site. Add "holds": "${name}" to the page they go under.` };
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (holders.length > 1) {
|
|
67
|
+
const files = holders.map((h) => h.file);
|
|
68
|
+
out[name] = { ...none, problem: `${and(files)} ${files.length === 2 ? 'both' : 'all'} hold ${label}, but only one page can hold it, so its documents have no addresses. Take "holds" out of ${files.length === 2 ? 'one of them' : 'all but one'}.` };
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const page = holders[0]!;
|
|
72
|
+
const holder = { collection: page.collection, path: page.path, file: page.file, label: page.label };
|
|
73
|
+
if (page.path === 'index') {
|
|
74
|
+
out[name] = { ...none, holder, problem: `${page.file} is Home, which can't hold ${label} (its address is the whole site's), so ${label}'s documents have no addresses. Put "holds": "${name}" on another page.` };
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (page.warning || page.url === null) {
|
|
78
|
+
const why = page.warning ? ` ${page.warning.message}` : '';
|
|
79
|
+
out[name] = { ...none, holder, problem: `${page.file} holds ${label}, but it can't be opened, so ${label}'s documents have no addresses until it's fixed.${why}` };
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
out[name] = { collection: name, base: `${page.url.replace(/\/+$/, '')}/`, holder, hidden: page.hidden === true, problem: null };
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The site with each held collection's `urlBase` as resolved (null while it has a problem): what every address rule and the editor read. */
|
|
88
|
+
export function withHeldBases<C extends HoldableCollection, S extends { collections: Record<string, C> }>(site: S, bases: Readonly<Record<string, HeldBase>>): S {
|
|
89
|
+
const held = Object.values(bases);
|
|
90
|
+
if (held.length === 0) return site;
|
|
91
|
+
const collections = { ...site.collections };
|
|
92
|
+
for (const b of held) if (Object.hasOwn(collections, b.collection)) collections[b.collection] = { ...collections[b.collection]!, urlBase: b.base };
|
|
93
|
+
return { ...site, collections };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Does the site have a collection a page holds? (Most don't, and skip the extra listing.) */
|
|
97
|
+
export const hasHeld = (collections: Readonly<Record<string, HoldableCollection>>): boolean => Object.values(collections).some((c) => !!c.heldBy);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A held collection's word for its documents, as the editor's `singular` (documents/actions.ts)
|
|
101
|
+
* names one: a plural label as it is ("case studies", "posts"), any other with "entries"
|
|
102
|
+
* ("journal entries"). A held collection is flat and never shared blocks, so the label is all it takes.
|
|
103
|
+
*/
|
|
104
|
+
export function documentsNoun(label: string): string {
|
|
105
|
+
const l = label.toLowerCase();
|
|
106
|
+
return /ies$/.test(l) || /[^s]s$/.test(l) ? l : `${l} entries`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Decision 12's sentence: "Journal holds the journal entries. Delete the “holds” line in its
|
|
111
|
+
* file, or move the journal entries to another page, first." Delete… on the page says it instead of asking.
|
|
112
|
+
*/
|
|
113
|
+
export function holdsMessage(page: string, collection: string): string {
|
|
114
|
+
const noun = documentsNoun(collection);
|
|
115
|
+
return `${page} holds the ${noun}. Delete the “holds” line in its file, or move the ${noun} to another page, first.`;
|
|
116
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -58,7 +58,8 @@ import { lockupCss } from './lockup-css.ts';
|
|
|
58
58
|
import { shareBuildReads } from './read.ts';
|
|
59
59
|
import { writeSchemaFiles } from './cli/schemas.ts';
|
|
60
60
|
import { configFile } from './cli/site.ts';
|
|
61
|
-
import {
|
|
61
|
+
import { pagesRoutes } from './code-routes-disk.ts';
|
|
62
|
+
import { codeRoutesFrom } from './code-routes.ts';
|
|
62
63
|
import { GUARD_MIDDLEWARE, LOGIN_ROUTES } from './live/auth/routes.ts';
|
|
63
64
|
import { HOST_COMMIT_VARIABLES } from './live/mode.ts';
|
|
64
65
|
|
|
@@ -352,7 +353,8 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
352
353
|
const configName = configFile(site.root);
|
|
353
354
|
if (configName) addWatchFile(join(site.root, configName));
|
|
354
355
|
// M8 Decision 10: the site's own routes, read once here, for the editor's server and browser alike.
|
|
355
|
-
site.
|
|
356
|
+
site.pageRoutes = pagesRoutes(site.root, astroConfig.srcDir ? fileURLToPath(astroConfig.srcDir) : 'src');
|
|
357
|
+
site.codeRoutes = codeRoutesFrom(site.pageRoutes, Object.values(site.collections));
|
|
356
358
|
// M5b Decision 5: local dev (and a local build) read the media variables from the site's
|
|
357
359
|
// `.env`, which Astro doesn't put in process.env. Only those names; a set value wins.
|
|
358
360
|
const siteEnv = await loadSiteEnv(command === 'dev' ? 'development' : 'production', site.root);
|
package/src/live/README.md
CHANGED
|
@@ -7,7 +7,7 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
|
|
|
7
7
|
| File | What |
|
|
8
8
|
|---|---|
|
|
9
9
|
| `mode.ts` | `readMode(env, production)` → `local`, `live` (with `LiveSettings`) or `unconfigured` (`missing`, `problems`); `unconfiguredMessage()` for the 503 |
|
|
10
|
-
| `runtime.ts` | `liveRuntime(site, settings)`: one per process, lazy. `createLiveRuntime({...})` for tests (inject backend, Redis, clock). `CARACMS_GITHUB=memory`: a MemoryBackend seeded from disk (`siteFiles`), refused in production |
|
|
10
|
+
| `runtime.ts` | `liveRuntime(site, settings)`: one per process, lazy. With a cloud key it reads the repo from the cloud and takes its first token before anything else, so a refusal stops the start (the guard answers 503 with the cloud's sentence; the next request tries again). `createLiveRuntime({...})` for tests (inject backend, Redis, clock). `CARACMS_GITHUB=memory`: a MemoryBackend seeded from disk (`siteFiles`), refused in production |
|
|
11
11
|
| `git.ts` | `LiveGit`: the `drafts` copy (`files()`, `read()`, `contentSource()`), `commitDrafts()`, `check()`, `publish()` and its `commitPending` hook, `waiting()`, `publishStatus()`, `history()`, `tryAgain()`. M5a: a draft entry with `text` null removes the file; "Contact (deleted)" in the headlines; `mainHas`, `changeOf` (new, deleted or changed), `titleFor` |
|
|
12
12
|
| `timings.ts` | `LiveTimings` and `DEFAULT_TIMINGS`: every timing, Phase 2's sweep and lock ones included |
|
|
13
13
|
| `merge.ts` | `mergeFile({ base, draft, other })`: the per-key three-way merge, pure and byte-preserving |
|
|
@@ -18,7 +18,8 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
|
|
|
18
18
|
| `notices.ts` | Redis diet. `Notices`: "published as it stood" notices and dismissed "draft kept" ones, written through the status cache |
|
|
19
19
|
| `backend.ts` | `GitBackend`; `GitHubBackend`, `MemoryBackend` (test helpers: `seed`, `push`, `file`, `deployments`, `beforeRequest`); `StaleHeadError` |
|
|
20
20
|
| `branches.ts` | `Branch` (a role: `main` or `drafts`), `BranchNames` (the refs behind them), `DEFAULT_BRANCHES`. `CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH` (read by `mode.ts`, M9 Decision 6) name the branch Publish commits to and the one autosaves land on; unset, `main` and `drafts`. They're set together or not at all: one alone is a problem, so the mode is unconfigured (`/admin` answers 503 saying both must be set; the public site still serves). Only the backends turn a role into a ref name, so with both set a branch called `main` is never read or moved. `MemoryBackend.refs` is keyed by name |
|
|
21
|
-
| `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts) |
|
|
21
|
+
| `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts). A third credential, `cloud`: the token from CaraCMS Cloud, refreshed the same way; its refusals are an access problem of kind `cloud` |
|
|
22
|
+
| `cloud.ts` | CaraCMS Cloud (`CARACMS_CLOUD_KEY`; caracms-cloud M2): `DEFAULT_CLOUD_URL`, `cloudSettings()` (the repo, at start), `cloudToken()`, `CloudRefusal` (the cloud's `error`, `message`, `installUrl`), and `cloudUnavailable()`, the sign-in guard's 503 for a refusal at start |
|
|
22
23
|
| `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
|
|
23
24
|
| `autosaves.ts` | Phase 2. Autosaves in Redis (`caracms:autosave:<file>`, a week; stored compare-and-set, ordered per tab by `X-Caracms-Seq`), the session's last commit (the merge's ancestor) and who last changed a document (Decision 11; no expiry, and `known: false` when neither Redis nor git can say). M5a: a new document's autosave carries `created: { over }` (its ancestor until committed), a deletion is a "gone" autosave (`text` and `document` null, `gone: { title }`) |
|
|
24
25
|
| `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll (Redis diet: only when `needsBeat`), lapse only once committed, releasing, Edit here; presence, each record holding for twice its tab's poll interval plus 3 s |
|
|
@@ -14,11 +14,18 @@
|
|
|
14
14
|
* answering 503 here until the GitHub App's variables were set). A GET to it is as
|
|
15
15
|
* unavailable as any other guarded path, as live mode's let-through is POST-only too.
|
|
16
16
|
*
|
|
17
|
+
* - **A start that fails** answers 503 and the next request tries again (runtime.ts). When
|
|
18
|
+
* CaraCMS Cloud refused the start or couldn't be reached (caracms-cloud M2 Decision 14), the
|
|
19
|
+
* answer is the cloud's own sentence, as an HTML page with its install link as a link
|
|
20
|
+
* (JSON under `/_caracms/`, as ../../editor/middleware.ts answers); any other failure keeps
|
|
21
|
+
* the plain sentence. Neither the log line nor the page carries the site key or a token.
|
|
22
|
+
*
|
|
17
23
|
* The mode is Phase 2's `editorMode()`, so the two agree on live and local.
|
|
18
24
|
*/
|
|
19
25
|
import type { MiddlewareHandler } from 'astro';
|
|
20
26
|
import { site } from 'virtual:caracms/config';
|
|
21
27
|
import { editorMode } from '../../editor/server/mode.ts';
|
|
28
|
+
import { CloudRefusal, cloudUnavailable } from '../cloud.ts';
|
|
22
29
|
import { setPerson } from '../locals.ts';
|
|
23
30
|
import { guard, isFormEndpoint, isGuardedPath } from './guard.ts';
|
|
24
31
|
import { authFor } from './index.ts';
|
|
@@ -35,6 +42,10 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
|
|
|
35
42
|
try {
|
|
36
43
|
auth = await authFor(site, mode.settings, context.site);
|
|
37
44
|
} catch (error) {
|
|
45
|
+
if (error instanceof CloudRefusal) {
|
|
46
|
+
console.error(`[caracms] sign-in: the live runtime could not start: CaraCMS Cloud (${error.error}): ${error.message}`);
|
|
47
|
+
return cloudUnavailable(error, context.url.pathname.startsWith('/_caracms/'));
|
|
48
|
+
}
|
|
38
49
|
console.error('[caracms] sign-in: the live runtime could not start:', error);
|
|
39
50
|
return unavailable("The editor couldn't start. Try again in a moment.");
|
|
40
51
|
}
|
package/src/live/autosaves.ts
CHANGED
|
@@ -120,6 +120,21 @@ export interface Read<T> {
|
|
|
120
120
|
|
|
121
121
|
/** The Redis hash of moves waiting to publish (old file → new file). */
|
|
122
122
|
export const movesKey = (prefix: string) => `${prefix}moves`;
|
|
123
|
+
/**
|
|
124
|
+
* Collections under pages: the moves hash also holds each moved document's live address,
|
|
125
|
+
* as `@<file now>` → the address (see `recordAddresses`), so the status poll reads no more
|
|
126
|
+
* keys than before (Redis diet). `splitMoves` parts the two.
|
|
127
|
+
*/
|
|
128
|
+
export const ADDRESS_FIELD = '@';
|
|
129
|
+
export function splitMoves(fields: Iterable<[string, string]>): { records: Map<string, string>; addresses: Map<string, string> } {
|
|
130
|
+
const records = new Map<string, string>();
|
|
131
|
+
const addresses = new Map<string, string>();
|
|
132
|
+
for (const [k, v] of fields) {
|
|
133
|
+
if (k.startsWith(ADDRESS_FIELD)) addresses.set(k.slice(ADDRESS_FIELD.length), v);
|
|
134
|
+
else records.set(k, v);
|
|
135
|
+
}
|
|
136
|
+
return { records, addresses };
|
|
137
|
+
}
|
|
123
138
|
|
|
124
139
|
const parse = <T>(raw: string | null): T | null => {
|
|
125
140
|
if (raw === null) return null;
|
|
@@ -243,7 +258,7 @@ export class Autosaves {
|
|
|
243
258
|
|
|
244
259
|
/** Pre-M7 item 8: the moves waiting to publish, old file → new file. */
|
|
245
260
|
async moves(): Promise<Map<string, string>> {
|
|
246
|
-
return
|
|
261
|
+
return splitMoves(Object.entries(await this.redis.hgetall(movesKey(this.prefix)))).records;
|
|
247
262
|
}
|
|
248
263
|
|
|
249
264
|
private async setMove(from: string, to: string): Promise<void> {
|
|
@@ -271,11 +286,83 @@ export class Autosaves {
|
|
|
271
286
|
} else if (wasLive) await this.setMove(from, to);
|
|
272
287
|
}
|
|
273
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Several documents moved at once (the many-move engine), each as `recordMove` would,
|
|
291
|
+
* but every one read against the records before the plan: in a chain (A → B, B → C)
|
|
292
|
+
* the second move's `from` is the first's `to`, and read one at a time it would take
|
|
293
|
+
* over the first's record. In a swap each gets its record (`movesIn` counts neither
|
|
294
|
+
* while both files are there).
|
|
295
|
+
*/
|
|
296
|
+
async recordMoves(entries: readonly { from: string; to: string; wasLive: boolean }[]): Promise<void> {
|
|
297
|
+
if (entries.length === 0) return;
|
|
298
|
+
const before = await this.moves();
|
|
299
|
+
const after = new Map(before);
|
|
300
|
+
const origins = entries.map((e) => [...before].find(([, t]) => t === e.from)?.[0] ?? (e.wasLive ? e.from : null));
|
|
301
|
+
for (const origin of origins) if (origin !== null) after.delete(origin);
|
|
302
|
+
entries.forEach((e, i) => {
|
|
303
|
+
const origin = origins[i]!;
|
|
304
|
+
if (origin !== null && origin !== e.to) after.set(origin, e.to);
|
|
305
|
+
});
|
|
306
|
+
const drop = [...before.keys()].filter((f) => !after.has(f));
|
|
307
|
+
if (drop.length) await this.dropMoves(...drop);
|
|
308
|
+
for (const [f, t] of after) if (before.get(f) !== t) await this.setMove(f, t);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Collections under pages: each moved document's live address, by its file now. */
|
|
312
|
+
async addresses(): Promise<Map<string, string>> {
|
|
313
|
+
return splitMoves(Object.entries(await this.redis.hgetall(movesKey(this.prefix)))).addresses;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Collections under pages (Decision 9): each address a move changed, in one write. The
|
|
318
|
+
* record is the document's live address, by its file now: a held document keeps its file
|
|
319
|
+
* when its holder moves, so its old address can't be worked out from a file's move, nor
|
|
320
|
+
* from its holder's address now. Moved since the last Publish already: the record follows
|
|
321
|
+
* it to its new file; back at its live address: the record goes. A document that was
|
|
322
|
+
* never live (`wasLive` false, no record) gets none.
|
|
323
|
+
*/
|
|
324
|
+
async recordAddresses(entries: readonly { from: string; to: string; toUrl: string; fromUrl: string; wasLive: boolean }[]): Promise<void> {
|
|
325
|
+
if (entries.length === 0) return;
|
|
326
|
+
// Every live address from the records as they were before the plan, never from the
|
|
327
|
+
// map being changed: in a swap or a chain one entry's `to` is another's `from`.
|
|
328
|
+
const before = await this.addresses();
|
|
329
|
+
const same = (a: string, b: string) => a.replace(/\/+$/, '').toLowerCase() === b.replace(/\/+$/, '').toLowerCase();
|
|
330
|
+
const live = entries.map((e) => before.get(e.from) ?? (e.wasLive ? e.fromUrl : null));
|
|
331
|
+
const after = new Map(before);
|
|
332
|
+
for (const e of entries) if (e.from !== e.to) after.delete(e.from);
|
|
333
|
+
entries.forEach((e, i) => {
|
|
334
|
+
const address = live[i]!;
|
|
335
|
+
if (address === null || same(address, e.toUrl)) after.delete(e.to);
|
|
336
|
+
else after.set(e.to, address);
|
|
337
|
+
});
|
|
338
|
+
const drop = new Set([...before.keys()].filter((f) => !after.has(f)));
|
|
339
|
+
const set: Record<string, string> = {};
|
|
340
|
+
for (const [f, v] of after) if (before.get(f) !== v) set[f] = v;
|
|
341
|
+
const key = movesKey(this.prefix);
|
|
342
|
+
const field = (file: string) => `${ADDRESS_FIELD}${file}`;
|
|
343
|
+
if (drop.size) {
|
|
344
|
+
const fields = [...drop].map(field);
|
|
345
|
+
await landed(this.cache, key, () => this.redis.hdel(key, ...fields));
|
|
346
|
+
this.cache?.hash(key, {}, fields);
|
|
347
|
+
}
|
|
348
|
+
if (Object.keys(set).length) {
|
|
349
|
+
const fields = Object.fromEntries(Object.entries(set).map(([f, v]) => [field(f), v]));
|
|
350
|
+
await landed(this.cache, key, () => this.redis.hset(key, fields));
|
|
351
|
+
this.cache?.hash(key, fields);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
274
355
|
/** A deleted document's move, if it was moved, is no move any more: it's a deletion. */
|
|
275
356
|
async forgetMoveTo(file: string): Promise<void> {
|
|
276
357
|
const moves = await this.moves();
|
|
277
358
|
const olds = [...moves].filter(([, t]) => t === file).map(([o]) => o);
|
|
278
359
|
if (olds.length) await this.dropMoves(...olds);
|
|
360
|
+
if ((await this.addresses()).has(file)) {
|
|
361
|
+
const key = movesKey(this.prefix);
|
|
362
|
+
const field = `${ADDRESS_FIELD}${file}`;
|
|
363
|
+
await landed(this.cache, key, () => this.redis.hdel(key, field));
|
|
364
|
+
this.cache?.hash(key, {}, [field]);
|
|
365
|
+
}
|
|
279
366
|
}
|
|
280
367
|
|
|
281
368
|
/** The merge's ancestor for an autosave (Decision 6). See `ancestorFor`. */
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CaraCMS Cloud as the site's GitHub credential (caracms-cloud M2, Decisions 11–14).
|
|
3
|
+
*
|
|
4
|
+
* - `CARACMS_CLOUD_KEY` (the site key, `cck_…`) is sent as `Authorization: Bearer` and
|
|
5
|
+
* nowhere else; `CARACMS_CLOUD_URL` overrides the production address below.
|
|
6
|
+
* - `cloudSettings()`: `GET /v1/settings` at start, for the site's repo (`github.repo`;
|
|
7
|
+
* null is `no_repo`, with the cloud's own sentence).
|
|
8
|
+
* - `cloudToken()`: `POST /v1/github/token`, a fresh installation token on the site's repo,
|
|
9
|
+
* about an hour long. `GitHubClient` calls it and refreshes five minutes early.
|
|
10
|
+
* - **A refusal** (any answer that isn't 2xx, carrying `{ error, message }`) throws a
|
|
11
|
+
* `CloudRefusal` with the cloud's `message` as it came, and `installUrl` when there is
|
|
12
|
+
* one. A cloud that can't be reached, or answers with something else, throws a
|
|
13
|
+
* `CloudRefusal` whose `error` is `unreachable`, with the package's own sentence.
|
|
14
|
+
*
|
|
15
|
+
* - `cloudUnavailable()`: the sign-in guard's 503 for a refusal at start (Decision 14),
|
|
16
|
+
* the sentence escaped and the install link a link.
|
|
17
|
+
*
|
|
18
|
+
* The key and the tokens are never logged and never put in an error.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The production API's address (caracms-cloud M0 → Contracts). The one place it's written. */
|
|
22
|
+
export const DEFAULT_CLOUD_URL = 'https://glad-bird-412.ap-southeast-2.convex.site';
|
|
23
|
+
|
|
24
|
+
/** A site key: `cck_` and 43 base64url characters (caracms-cloud M1). */
|
|
25
|
+
export const CLOUD_KEY_SHAPE = /^cck_[A-Za-z0-9_-]{43}$/;
|
|
26
|
+
|
|
27
|
+
export const CLOUD_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so the editor can't start. Try again in a minute.";
|
|
28
|
+
/** The cloud's own `no_repo` sentence (Contracts), for `github.repo: null` in the settings. */
|
|
29
|
+
export const CLOUD_NO_REPO = 'This site has no GitHub repo in CaraCMS Cloud yet. Ask CaraCMS to set one.';
|
|
30
|
+
/** Decision 13: a token answer named another repo than the one the server started with. */
|
|
31
|
+
export const CLOUD_REPO_CHANGED = (repo: string) => `This site's repo was changed in CaraCMS Cloud to ${repo}. Restart the server to use it.`;
|
|
32
|
+
|
|
33
|
+
/** How long a call to the cloud may take before it counts as unreachable. */
|
|
34
|
+
export const CLOUD_TIMEOUT_MS = 20_000;
|
|
35
|
+
|
|
36
|
+
export interface CloudConnection {
|
|
37
|
+
key: string;
|
|
38
|
+
url: string;
|
|
39
|
+
/** For tests. Default: global fetch. */
|
|
40
|
+
fetch?: typeof fetch;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The cloud said no (or couldn't be reached). `message` is safe to show and to log. */
|
|
45
|
+
export class CloudRefusal extends Error {
|
|
46
|
+
/** The cloud's `error` (`no_repo`, `app_not_installed`, `unknown_key`…), or the package's `unreachable` / `repo_changed`. */
|
|
47
|
+
readonly error: string;
|
|
48
|
+
/** An `https://` link the message names (the App's install page), or null. */
|
|
49
|
+
readonly installUrl: string | null;
|
|
50
|
+
constructor(error: string, message: string, installUrl: string | null = null) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'CloudRefusal';
|
|
53
|
+
this.error = error;
|
|
54
|
+
this.installUrl = installUrl;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const unreachable = () => new CloudRefusal('unreachable', CLOUD_UNREACHABLE);
|
|
59
|
+
|
|
60
|
+
async function call(connection: CloudConnection, method: 'GET' | 'POST', path: string): Promise<Record<string, unknown>> {
|
|
61
|
+
let res: Response;
|
|
62
|
+
let body: unknown;
|
|
63
|
+
try {
|
|
64
|
+
res = await (connection.fetch ?? fetch)(`${connection.url}${path}`, {
|
|
65
|
+
method,
|
|
66
|
+
headers: { authorization: `Bearer ${connection.key}`, accept: 'application/json', 'user-agent': 'caracms' },
|
|
67
|
+
signal: AbortSignal.timeout(connection.timeoutMs ?? CLOUD_TIMEOUT_MS),
|
|
68
|
+
});
|
|
69
|
+
body = await res.json();
|
|
70
|
+
} catch {
|
|
71
|
+
throw unreachable();
|
|
72
|
+
}
|
|
73
|
+
const answer = body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
|
|
74
|
+
if (!answer) throw unreachable();
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
const { error, message, installUrl } = answer;
|
|
77
|
+
if (typeof error !== 'string' || typeof message !== 'string' || !message.trim()) throw unreachable();
|
|
78
|
+
throw new CloudRefusal(error, message, typeof installUrl === 'string' && /^https:\/\//i.test(installUrl) ? installUrl : null);
|
|
79
|
+
}
|
|
80
|
+
return answer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const REPO = /^([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/;
|
|
84
|
+
|
|
85
|
+
/** The site's repo, from `GET /v1/settings`. */
|
|
86
|
+
export async function cloudSettings(connection: CloudConnection): Promise<{ owner: string; repo: string }> {
|
|
87
|
+
const answer = await call(connection, 'GET', '/v1/settings');
|
|
88
|
+
const github = answer.github as { repo?: unknown } | undefined;
|
|
89
|
+
if (!github || typeof github !== 'object') throw unreachable();
|
|
90
|
+
if (github.repo === null) throw new CloudRefusal('no_repo', CLOUD_NO_REPO);
|
|
91
|
+
const m = typeof github.repo === 'string' ? REPO.exec(github.repo) : null;
|
|
92
|
+
if (!m) throw unreachable();
|
|
93
|
+
return { owner: m[1]!, repo: m[2]! };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A fresh installation token on the site's repo, from `POST /v1/github/token`. */
|
|
97
|
+
export async function cloudToken(connection: CloudConnection): Promise<{ token: string; expiresAt: number; repo: string }> {
|
|
98
|
+
const answer = await call(connection, 'POST', '/v1/github/token');
|
|
99
|
+
const { token, expiresAt, repo } = answer;
|
|
100
|
+
const at = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
|
|
101
|
+
if (typeof token !== 'string' || !token || Number.isNaN(at) || typeof repo !== 'string') throw unreachable();
|
|
102
|
+
return { token, expiresAt: at, repo };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const escapeHtml = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The message as HTML, escaped, with `link` (when there is one) as a link: where the
|
|
109
|
+
* message names it, in place; otherwise after it. The message's words are never changed.
|
|
110
|
+
*/
|
|
111
|
+
export function messageHtml(message: string, link: string | null): string {
|
|
112
|
+
if (!link) return escapeHtml(message);
|
|
113
|
+
const a = `<a href="${escapeHtml(link)}">${escapeHtml(link)}</a>`;
|
|
114
|
+
const parts = message.split(link);
|
|
115
|
+
return parts.length > 1 ? parts.map(escapeHtml).join(a) : `${escapeHtml(message)} ${a}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The cloud's refusal at start (Decision 14): its sentence, the install link as a link. */
|
|
119
|
+
export function cloudUnavailable(refusal: CloudRefusal, api: boolean): Response {
|
|
120
|
+
const headers = { 'Cache-Control': 'private, no-store' };
|
|
121
|
+
if (api) {
|
|
122
|
+
return new Response(JSON.stringify({ error: refusal.message, code: refusal.error, installUrl: refusal.installUrl }), {
|
|
123
|
+
status: 503,
|
|
124
|
+
headers: { ...headers, 'Content-Type': 'application/json; charset=utf-8' },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return new Response(
|
|
128
|
+
`<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>Editor unavailable</title><p>${messageHtml(refusal.message, refusal.installUrl)}</p>`,
|
|
129
|
+
{ status: 503, headers: { ...headers, 'Content-Type': 'text/html; charset=utf-8' } },
|
|
130
|
+
);
|
|
131
|
+
}
|