caracms 0.1.0 → 0.3.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 +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +65 -20
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +17 -3
- package/skills/caracms-adopt/look-same.mjs +24 -11
- package/skills/caracms-blocks/SKILL.md +69 -4
- package/skills/caracms-content/SKILL.md +2 -1
- package/skills/caracms-tina/SKILL.md +431 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cli/validate.ts +10 -5
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +20 -1
- package/src/components/Form.astro +76 -10
- package/src/components/Head.astro +4 -2
- package/src/components/Media.astro +27 -1
- package/src/components/RichText.astro +12 -4
- package/src/components/Text.astro +32 -9
- package/src/components/form-client.ts +7 -4
- package/src/components/sitemap.xml.ts +2 -1
- package/src/config.ts +32 -1
- package/src/content-schema.ts +7 -4
- package/src/content.ts +28 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +17 -12
- package/src/editor/client/fields/ReferenceField.tsx +6 -0
- package/src/editor/client/fields/RichTextField.tsx +38 -32
- package/src/editor/client/fields/host.tsx +5 -0
- package/src/editor/client/fields/parts.tsx +2 -1
- package/src/editor/client/fields/rich-text-doc.ts +28 -2
- package/src/editor/client/fields/rich-text-schema.ts +41 -2
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/nav/DocRow.tsx +6 -0
- package/src/editor/client/overlays/rich-text-bar.ts +6 -6
- package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/preview/driver.ts +5 -5
- package/src/editor/client/preview/field-text.ts +40 -2
- package/src/editor/client/preview/page-edit.ts +2 -2
- package/src/editor/client/preview/page-editor-registry.ts +4 -3
- package/src/editor/client/preview/page-editor.ts +3 -3
- package/src/editor/client/state/document-store.ts +20 -2
- package/src/editor/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/editor.css +2 -0
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +27 -6
- package/src/fields.ts +63 -15
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +17 -4
- package/src/index.ts +53 -7
- package/src/links.ts +35 -0
- package/src/live/README.md +5 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +54 -24
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +177 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +15 -4
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +40 -7
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- package/src/serialize.ts +10 -1
package/src/live/runtime.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
19
19
|
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
|
+
import { DEFAULT_BRANCHES } from './branches.ts';
|
|
22
23
|
import { GitHubClient, Tracker } from './github.ts';
|
|
23
24
|
import { LiveGit, SITE_SETTINGS_FILE } from './git.ts';
|
|
24
25
|
import type { LiveSettings } from './mode.ts';
|
|
@@ -49,6 +50,8 @@ export interface RuntimeOptions {
|
|
|
49
50
|
log?: (line: string) => void;
|
|
50
51
|
runningCommit?: () => string | null;
|
|
51
52
|
railwayFetch?: typeof fetch;
|
|
53
|
+
/** Tests: the deploy hook's fetch. */
|
|
54
|
+
deployHookFetch?: typeof fetch;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
/** Build and start a runtime (not shared: tests make their own). */
|
|
@@ -62,16 +65,19 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
|
|
|
62
65
|
if (settings.production || process.env.NODE_ENV === 'production') {
|
|
63
66
|
throw new Error('caracms: the in-memory GitHub (CARACMS_GITHUB=memory) is refused in production.');
|
|
64
67
|
}
|
|
65
|
-
const memory = new MemoryBackend(new Tracker(now), { now });
|
|
68
|
+
const memory = new MemoryBackend(new Tracker(now), { now, branches: settings.branches });
|
|
66
69
|
memory.seed(await siteFiles(site));
|
|
67
70
|
log('live mode on the in-memory GitHub, seeded from the site\'s files on disk (CARACMS_GITHUB=memory)');
|
|
68
71
|
backend = memory;
|
|
69
72
|
} else {
|
|
70
73
|
const tracker = new Tracker(now);
|
|
71
|
-
const
|
|
72
|
-
|
|
74
|
+
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);
|
|
73
77
|
}
|
|
74
78
|
}
|
|
79
|
+
const { main, drafts } = backend.branches;
|
|
80
|
+
if (main !== DEFAULT_BRANCHES.main || drafts !== DEFAULT_BRANCHES.drafts) log(`publishing to ${main}, autosaving to ${drafts} (CARACMS_MAIN_BRANCH, CARACMS_DRAFTS_BRANCH)`);
|
|
75
81
|
const redis = options.redis ?? (settings.redisUrl ? connectRedis(settings.redisUrl, { log }) : memoryRedis({ now }));
|
|
76
82
|
if (!options.redis && !settings.redisUrl) log('no REDIS_URL: using the in-memory Redis stand-in (dev only)');
|
|
77
83
|
const git = new LiveGit({
|
|
@@ -83,7 +89,12 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
|
|
|
83
89
|
log,
|
|
84
90
|
runningCommit: options.runningCommit,
|
|
85
91
|
timings: settings.timings,
|
|
86
|
-
|
|
92
|
+
bakedCommit: settings.bakedCommit,
|
|
93
|
+
redeployer: settings.deployHook
|
|
94
|
+
? { kind: 'hook', url: settings.deployHook, fetch: options.deployHookFetch }
|
|
95
|
+
: settings.railway
|
|
96
|
+
? { kind: 'railway', ...settings.railway, fetch: options.railwayFetch }
|
|
97
|
+
: null,
|
|
87
98
|
});
|
|
88
99
|
// The copy only: the startup check waits for the live editor (editor.ts), which fills
|
|
89
100
|
// `commitPending` first, so a merge from main never runs ahead of pending autosaves (Decision 8).
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status poll's write-through cache (Redis diet Decisions 1–5).
|
|
3
|
+
*
|
|
4
|
+
* Every Redis write a site makes goes through its one server, so what the status route
|
|
5
|
+
* reads can be kept here and updated (or dropped) by the writes themselves, instead of
|
|
6
|
+
* being asked of Redis on every poll.
|
|
7
|
+
*
|
|
8
|
+
* - **What it holds is Redis as last read or written here, by key**: strings, hashes,
|
|
9
|
+
* sets, and scans (every key under a prefix, `caracms:autosave:`, with its value).
|
|
10
|
+
* Records, never views made from them: whether a lock holds, or which notices are a
|
|
11
|
+
* person's, is worked out per poll (Watch-outs).
|
|
12
|
+
* - **Only the status route reads it** (Decision 3). Writes, create, delete, move, the
|
|
13
|
+
* sweep, Publish and sign-in read Redis directly.
|
|
14
|
+
* - **An entry is re-read once it's `statusCacheMs` old, counted from its last Redis
|
|
15
|
+
* read** (Decision 1): a write-through keeps it current but doesn't make it younger, so
|
|
16
|
+
* what bypasses this server (the other server during a deploy, a key expiring, a hand
|
|
17
|
+
* edit) shows within that time (Decision 4).
|
|
18
|
+
* - **The writes are the modules'** (Decision 2): autosaves.ts, locks.ts, git.ts and
|
|
19
|
+
* notices.ts tell it each write as it lands. A write it can apply exactly (a value just
|
|
20
|
+
* set, a field just written) updates the entry; anything else (a compare-and-swap that
|
|
21
|
+
* lost, so the value is unknown) drops it. A write to an entry not held is only noted.
|
|
22
|
+
* - **Refreshes are single-flight** (Decision 5): a poll needing an entry another poll is
|
|
23
|
+
* already reading waits for that read. The ones it's missing go in one batch (SCANs
|
|
24
|
+
* with the rest, then their MGETs). A refresh that fails caches nothing, and nothing
|
|
25
|
+
* refreshes on a timer: with no poll there's no Redis read, so Redis can sleep.
|
|
26
|
+
* - **A write that lands while an entry is being read** may be missing from what the
|
|
27
|
+
* read brings back: that entry is kept for the polls waiting on it, but as already
|
|
28
|
+
* old, so the next poll reads it again.
|
|
29
|
+
*/
|
|
30
|
+
import type { BatchCommand, RedisClient } from './redis.ts';
|
|
31
|
+
|
|
32
|
+
type Entry =
|
|
33
|
+
| { kind: 'string'; value: string | null; at: number }
|
|
34
|
+
| { kind: 'hash'; value: Map<string, string>; at: number }
|
|
35
|
+
| { kind: 'set'; value: Set<string>; at: number }
|
|
36
|
+
| { kind: 'scan'; value: Map<string, string>; at: number };
|
|
37
|
+
|
|
38
|
+
type Kind = Entry['kind'];
|
|
39
|
+
|
|
40
|
+
/** What a poll reads: keys by kind, and scans by key prefix (`caracms:lock:`). */
|
|
41
|
+
export interface StatusReads {
|
|
42
|
+
scans?: readonly string[];
|
|
43
|
+
hashes?: readonly string[];
|
|
44
|
+
sets?: readonly string[];
|
|
45
|
+
strings?: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One poll's view of the cache. Its entries are the cache's own, so the poll's later write-throughs show in it. */
|
|
49
|
+
export interface StatusSnapshot {
|
|
50
|
+
/** Every key under the prefix, with its value. */
|
|
51
|
+
scan(prefix: string): ReadonlyMap<string, string>;
|
|
52
|
+
hash(key: string): ReadonlyMap<string, string>;
|
|
53
|
+
set(key: string): ReadonlySet<string>;
|
|
54
|
+
string(key: string): string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const name = (kind: Kind, key: string) => `${kind} ${key}`;
|
|
58
|
+
|
|
59
|
+
export class StatusCache {
|
|
60
|
+
private readonly redis: RedisClient;
|
|
61
|
+
private readonly maxAgeMs: number;
|
|
62
|
+
private readonly now: () => number;
|
|
63
|
+
private readonly entries = new Map<string, Entry>();
|
|
64
|
+
private readonly reading = new Map<string, Promise<Map<string, Entry>>>();
|
|
65
|
+
/** Write-throughs so far, and the count at each entry's latest (for a read that a write overtook). */
|
|
66
|
+
private writes = 0;
|
|
67
|
+
private readonly written = new Map<string, number>();
|
|
68
|
+
|
|
69
|
+
constructor(options: { redis: RedisClient; maxAgeMs: number; now: () => number }) {
|
|
70
|
+
this.redis = options.redis;
|
|
71
|
+
this.maxAgeMs = options.maxAgeMs;
|
|
72
|
+
this.now = options.now;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What a poll reads, from memory where it's fresh enough, else from Redis (one batch, shared with any poll waiting on the same). */
|
|
76
|
+
async read(reads: StatusReads): Promise<StatusSnapshot> {
|
|
77
|
+
const wanted: [Kind, string][] = [
|
|
78
|
+
...(reads.scans ?? []).map((k) => ['scan', k] as [Kind, string]),
|
|
79
|
+
...(reads.hashes ?? []).map((k) => ['hash', k] as [Kind, string]),
|
|
80
|
+
...(reads.sets ?? []).map((k) => ['set', k] as [Kind, string]),
|
|
81
|
+
...(reads.strings ?? []).map((k) => ['string', k] as [Kind, string]),
|
|
82
|
+
];
|
|
83
|
+
const now = this.now();
|
|
84
|
+
const waits = new Set<Promise<Map<string, Entry>>>();
|
|
85
|
+
const missing: [Kind, string][] = [];
|
|
86
|
+
for (const [kind, key] of wanted) {
|
|
87
|
+
const n = name(kind, key);
|
|
88
|
+
const inFlight = this.reading.get(n);
|
|
89
|
+
if (inFlight) waits.add(inFlight);
|
|
90
|
+
else {
|
|
91
|
+
const e = this.entries.get(n);
|
|
92
|
+
if (!e || now - e.at >= this.maxAgeMs) missing.push([kind, key]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (missing.length) {
|
|
96
|
+
const read = this.refresh(missing);
|
|
97
|
+
for (const [kind, key] of missing) this.reading.set(name(kind, key), read);
|
|
98
|
+
const done = () => {
|
|
99
|
+
for (const [kind, key] of missing) if (this.reading.get(name(kind, key)) === read) this.reading.delete(name(kind, key));
|
|
100
|
+
if (this.reading.size === 0) this.written.clear();
|
|
101
|
+
};
|
|
102
|
+
read.then(done, done);
|
|
103
|
+
waits.add(read);
|
|
104
|
+
}
|
|
105
|
+
const fetched = new Map<string, Entry>();
|
|
106
|
+
for (const got of await Promise.all(waits)) for (const [n, e] of got) fetched.set(n, e);
|
|
107
|
+
// The cache's own entry where it still has one (later write-throughs land in it), else what the read brought back,
|
|
108
|
+
// pinned now: a drop while the poll is still in its writes leaves it the records it read, never none.
|
|
109
|
+
const pinned = new Map<string, Entry | undefined>(wanted.map(([kind, key]) => [name(kind, key), this.entries.get(name(kind, key)) ?? fetched.get(name(kind, key))]));
|
|
110
|
+
const pick = (kind: Kind, key: string) => pinned.get(name(kind, key));
|
|
111
|
+
return {
|
|
112
|
+
scan: (prefix) => (pick('scan', prefix)?.value as Map<string, string> | undefined) ?? new Map(),
|
|
113
|
+
hash: (key) => (pick('hash', key)?.value as Map<string, string> | undefined) ?? new Map(),
|
|
114
|
+
set: (key) => (pick('set', key)?.value as Set<string> | undefined) ?? new Set(),
|
|
115
|
+
string: (key) => (pick('string', key)?.value as string | null | undefined) ?? null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read these entries from Redis: one batch, then one more for the scans' MGETs. */
|
|
120
|
+
private async refresh(entries: [Kind, string][]): Promise<Map<string, Entry>> {
|
|
121
|
+
const started = this.writes;
|
|
122
|
+
const at = this.now();
|
|
123
|
+
const commands = entries.map(([kind, key]): BatchCommand => {
|
|
124
|
+
if (kind === 'scan') return ['scan', `${key}*`];
|
|
125
|
+
if (kind === 'hash') return ['hgetall', key];
|
|
126
|
+
if (kind === 'set') return ['smembers', key];
|
|
127
|
+
return ['get', key];
|
|
128
|
+
});
|
|
129
|
+
const replies = (await this.redis.batch(commands)) as unknown[];
|
|
130
|
+
const scanned = entries.flatMap(([kind], i) => (kind === 'scan' && (replies[i] as string[]).length ? [i] : []));
|
|
131
|
+
const values = scanned.length ? ((await this.redis.batch(scanned.map((i): BatchCommand => ['mget', replies[i] as string[]]))) as (string | null)[][]) : [];
|
|
132
|
+
const out = new Map<string, Entry>();
|
|
133
|
+
entries.forEach(([kind, key], i) => {
|
|
134
|
+
const n = name(kind, key);
|
|
135
|
+
// Written since this read began: kept for now, but already old.
|
|
136
|
+
const age = (this.written.get(n) ?? 0) > started ? -Infinity : at;
|
|
137
|
+
let entry: Entry;
|
|
138
|
+
if (kind === 'scan') {
|
|
139
|
+
const keys = replies[i] as string[];
|
|
140
|
+
const got = values[scanned.indexOf(i)] ?? [];
|
|
141
|
+
const map = new Map<string, string>();
|
|
142
|
+
keys.forEach((k, j) => {
|
|
143
|
+
const v = got[j];
|
|
144
|
+
if (v !== null && v !== undefined) map.set(k, v);
|
|
145
|
+
});
|
|
146
|
+
entry = { kind, value: map, at: age };
|
|
147
|
+
} else if (kind === 'hash') entry = { kind, value: new Map(Object.entries(replies[i] as Record<string, string>)), at: age };
|
|
148
|
+
else if (kind === 'set') entry = { kind, value: new Set(replies[i] as string[]), at: age };
|
|
149
|
+
else entry = { kind, value: replies[i] as string | null, at: age };
|
|
150
|
+
this.entries.set(n, entry);
|
|
151
|
+
out.set(n, entry);
|
|
152
|
+
});
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Write-throughs (the modules call these once a write has landed) ─────────────
|
|
157
|
+
|
|
158
|
+
/** Note a write to these entry names; return the ones held. */
|
|
159
|
+
private touch(names: string[]): Entry[] {
|
|
160
|
+
this.writes++;
|
|
161
|
+
const held: Entry[] = [];
|
|
162
|
+
for (const n of names) {
|
|
163
|
+
// Only a read in flight needs to know (`refresh`); with none, nothing is remembered.
|
|
164
|
+
if (this.reading.size) this.written.set(n, this.writes);
|
|
165
|
+
const e = this.entries.get(n);
|
|
166
|
+
if (e) held.push(e);
|
|
167
|
+
}
|
|
168
|
+
return held;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The scan entries (held or not) whose prefix this key is under. */
|
|
172
|
+
private scansOf(key: string): string[] {
|
|
173
|
+
const out: string[] = [];
|
|
174
|
+
for (const n of [...this.entries.keys(), ...this.reading.keys()]) if (n.startsWith('scan ') && key.startsWith(n.slice(5))) out.push(n);
|
|
175
|
+
return [...new Set(out)];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A string key now holds `value` (null: deleted). */
|
|
179
|
+
string(key: string, value: string | null): void {
|
|
180
|
+
for (const e of this.touch([name('string', key), ...this.scansOf(key)])) {
|
|
181
|
+
if (e.kind === 'string') e.value = value;
|
|
182
|
+
else if (e.kind === 'scan') {
|
|
183
|
+
if (value === null) e.value.delete(key);
|
|
184
|
+
else e.value.set(key, value);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A hash's fields were set, or deleted. */
|
|
190
|
+
hash(key: string, set: Record<string, string>, del: readonly string[] = []): void {
|
|
191
|
+
for (const e of this.touch([name('hash', key)])) {
|
|
192
|
+
if (e.kind !== 'hash') continue;
|
|
193
|
+
for (const [f, v] of Object.entries(set)) e.value.set(f, v);
|
|
194
|
+
for (const f of del) e.value.delete(f);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Members were added to a set. */
|
|
199
|
+
add(key: string, ...members: string[]): void {
|
|
200
|
+
for (const e of this.touch([name('set', key)])) if (e.kind === 'set') for (const m of members) e.value.add(m);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The key changed in a way not known here (a compare-and-swap that lost): forget it, and any scan it's under. */
|
|
204
|
+
drop(key: string): void {
|
|
205
|
+
const names = [...(['string', 'hash', 'set'] as const).map((k) => name(k, key)), ...this.scansOf(key)];
|
|
206
|
+
this.touch(names);
|
|
207
|
+
for (const n of names) this.entries.delete(n);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Run a write to `key`; one that fails (lost in flight, so it may or may not have landed) drops the key from the cache. */
|
|
212
|
+
export async function landed<T>(cache: StatusCache | null, key: string, write: () => Promise<T>): Promise<T> {
|
|
213
|
+
try {
|
|
214
|
+
return await write();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
cache?.drop(key);
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/live/status.ts
CHANGED
|
@@ -62,7 +62,11 @@ export interface PublishRecord {
|
|
|
62
62
|
tryAgainAt: string | null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
/**
|
|
66
|
+
* `slow` (runs-anywhere Decision 10): still not live well after it was made, or after its
|
|
67
|
+
* last Try again, with no failure known: "taking longer than usual. It may have failed."
|
|
68
|
+
*/
|
|
69
|
+
export type PublishState = 'publishing' | 'live' | 'failed' | 'slow';
|
|
66
70
|
|
|
67
71
|
/** One GitHub Deployment and its newest status (null: no status yet). */
|
|
68
72
|
export interface DeploymentFact {
|
|
@@ -76,7 +80,7 @@ const FINAL = new Set(['success', 'failure', 'error', 'inactive']);
|
|
|
76
80
|
|
|
77
81
|
export interface PublishFacts {
|
|
78
82
|
record: PublishRecord;
|
|
79
|
-
/** The running server's commit (
|
|
83
|
+
/** The running server's commit (mode.ts → `runningCommitFrom`), or null with none known. */
|
|
80
84
|
running: string | null;
|
|
81
85
|
/** Is `running` the publish commit or a descendant of it? null when not known (not asked). */
|
|
82
86
|
runningIncludesPublish: boolean | null;
|
|
@@ -84,6 +88,9 @@ export interface PublishFacts {
|
|
|
84
88
|
laterOnMain: ReadonlySet<string>;
|
|
85
89
|
/** The repo's deployments, any order. */
|
|
86
90
|
deployments: DeploymentFact[];
|
|
91
|
+
/** Now (ms), and how long a publish may take before it reads as `slow` (`LiveTimings.publishSlowMs`). Either unset: never slow. */
|
|
92
|
+
now?: number;
|
|
93
|
+
slowAfterMs?: number;
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/**
|
|
@@ -94,6 +101,10 @@ export interface PublishFacts {
|
|
|
94
101
|
* - **failed**: the newest deployment that has *answered*, for the publish commit or
|
|
95
102
|
* a later commit on `main` (since the last Try again), says `failure` or `error`.
|
|
96
103
|
* A deployment stuck `in_progress` is never read as finished or failed.
|
|
104
|
+
* - **slow** (runs-anywhere Decision 10): not live and not failed, `slowAfterMs` after the
|
|
105
|
+
* publish was made, or after its last Try again (`tryAgainAt`). Without deployments a
|
|
106
|
+
* failure can never be known, and on Railway a skipped deploy stays `in_progress` for
|
|
107
|
+
* good; a running commit that isn't in the repo never includes the publish either.
|
|
97
108
|
* - **publishing**: otherwise.
|
|
98
109
|
*/
|
|
99
110
|
export function publishState(f: PublishFacts): { state: PublishState; assumed: boolean } {
|
|
@@ -105,5 +116,6 @@ export function publishState(f: PublishFacts): { state: PublishState; assumed: b
|
|
|
105
116
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
106
117
|
const newest = answered[0];
|
|
107
118
|
if (newest && (newest.state === 'failure' || newest.state === 'error')) return { state: 'failed', assumed: false };
|
|
119
|
+
if (f.now !== undefined && f.slowAfterMs !== undefined && f.now - Date.parse(f.record.tryAgainAt ?? f.record.at) >= f.slowAfterMs) return { state: 'slow', assumed: false };
|
|
108
120
|
return { state: 'publishing', assumed: false };
|
|
109
121
|
}
|
package/src/live/timings.ts
CHANGED
|
@@ -29,6 +29,22 @@ export interface LiveTimings {
|
|
|
29
29
|
autosaveTtlMs: number;
|
|
30
30
|
/** Decision 13: each tab polls the status this often (the editor's POLL_MS; /admin passes it to the page). */
|
|
31
31
|
pollMs: number;
|
|
32
|
+
/** Runs-anywhere Decision 10: a publish not live this long after it was made (or its last Try again) reads as taking longer than usual. */
|
|
33
|
+
publishSlowMs: number;
|
|
34
|
+
/** Runs-anywhere (Watch-outs): Publish's request answers by this long, well inside Cloudflare's 100 s; the Publish carries on past it. */
|
|
35
|
+
publishAnswerMs: number;
|
|
36
|
+
/** Redis diet Decision 1: the status poll's cached reads are re-read from Redis once this old (counted from the last Redis read). */
|
|
37
|
+
statusCacheMs: number;
|
|
38
|
+
/** Redis diet Decision 6: a tab's heartbeat goes only once its lock's last beat is this old (or to take the lock back). */
|
|
39
|
+
beatEveryMs: number;
|
|
40
|
+
/** Redis diet Decision 8: a visible tab with no input this long polls at `slowPollMs` (/admin passes it to the page). */
|
|
41
|
+
idleSlowAfterMs: number;
|
|
42
|
+
/** Redis diet Decision 8: an idle tab's poll interval (/admin passes it to the page). */
|
|
43
|
+
slowPollMs: number;
|
|
44
|
+
/** Redis diet Decision 10: once the tab holds the document, a keep goes this long after the last change (/admin passes it to the page). */
|
|
45
|
+
keepDelayMs: number;
|
|
46
|
+
/** Redis diet Decision 10: …and never later than this after the first change not yet sent (/admin passes it to the page). */
|
|
47
|
+
keepCapMs: number;
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
export const DEFAULT_TIMINGS: LiveTimings = {
|
|
@@ -44,4 +60,12 @@ export const DEFAULT_TIMINGS: LiveTimings = {
|
|
|
44
60
|
presenceMs: 15_000,
|
|
45
61
|
autosaveTtlMs: 7 * 24 * 60 * 60_000,
|
|
46
62
|
pollMs: 5000,
|
|
63
|
+
publishSlowMs: 15 * 60_000,
|
|
64
|
+
publishAnswerMs: 60_000,
|
|
65
|
+
statusCacheMs: 30_000,
|
|
66
|
+
beatEveryMs: 30_000,
|
|
67
|
+
idleSlowAfterMs: 2 * 60_000,
|
|
68
|
+
slowPollMs: 30_000,
|
|
69
|
+
keepDelayMs: 3000,
|
|
70
|
+
keepCapMs: 15_000,
|
|
47
71
|
};
|
package/src/media.ts
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* is refused (403).
|
|
13
13
|
* - A gravity goes only on a `fill` (a `fit` crops nothing; a gravity there would only
|
|
14
14
|
* make a second URL for the same file). Quality 80. WebP, always in the URL (never
|
|
15
|
-
* imgproxy's auto-format: Railway's CDN ignores `Vary: Accept`)
|
|
15
|
+
* imgproxy's auto-format: Railway's CDN ignores `Vary: Accept`); `@png` when the
|
|
16
|
+
* media field says `format: 'png'` (M9 Decision 8).
|
|
16
17
|
* - Sides are rounded and capped at 4000 (imgproxy's `IMGPROXY_MAX_RESULT_DIMENSION`).
|
|
17
18
|
* - GIFs and SVGs pass through: `/<sig>/plain/s3://<bucket>/<key>`, no resize, no
|
|
18
19
|
* format, still signed (a GIF resize drops its animation; an SVG is sharp at any size).
|
|
@@ -23,15 +24,16 @@
|
|
|
23
24
|
* fixed test key and salt and a test bucket, and is refused on a production server.
|
|
24
25
|
*/
|
|
25
26
|
import { createHmac } from 'node:crypto';
|
|
26
|
-
import type {
|
|
27
|
+
import type { ImageFormat } from './fields.ts';
|
|
28
|
+
import { renderMedia, type RenderImage } from './media-value.ts';
|
|
27
29
|
|
|
28
30
|
/** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
|
|
29
31
|
export const IMAGE_WIDTHS: readonly number[] = [160, 320, 480, 640, 960, 1280, 1920];
|
|
30
32
|
/** imgproxy's IMGPROXY_MAX_RESULT_DIMENSION: a side is never asked for above it. */
|
|
31
33
|
export const IMAGE_MAX_DIMENSION = 4000;
|
|
32
34
|
export const IMAGE_QUALITY = 80;
|
|
33
|
-
/** Pinned in every resized URL. */
|
|
34
|
-
export const IMAGE_FORMAT = 'webp';
|
|
35
|
+
/** Pinned in every resized URL, unless the media field asks for PNG (M9 Decision 8). */
|
|
36
|
+
export const IMAGE_FORMAT: ImageFormat = 'webp';
|
|
35
37
|
/** Fixed mode's densities (Decision 4: 1×, 2×, 3×). */
|
|
36
38
|
export const IMAGE_DENSITIES: readonly number[] = [1, 2, 3];
|
|
37
39
|
|
|
@@ -141,6 +143,8 @@ export interface ImageUrlOptions {
|
|
|
141
143
|
resize?: 'fit' | 'fill';
|
|
142
144
|
/** The focal point, fractions; used only on a `fill`. */
|
|
143
145
|
focal?: readonly [number, number] | null;
|
|
146
|
+
/** Default: WebP (M9 Decision 8). */
|
|
147
|
+
format?: ImageFormat;
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
/**
|
|
@@ -154,7 +158,7 @@ export function imageUrl(settings: MediaSettings, key: string, options: ImageUrl
|
|
|
154
158
|
if (!width && !height) throw new Error(`An image URL needs a width or a height (${key}).`);
|
|
155
159
|
const resize = options.resize ?? (width && height ? 'fill' : 'fit');
|
|
156
160
|
const gravity = resize === 'fill' && options.focal ? `/g:fp:${options.focal[0]}:${options.focal[1]}` : '';
|
|
157
|
-
return signed(settings, `/rs:${resize}:${width}:${height}${gravity}/q:${IMAGE_QUALITY}/plain/${sourceOf(settings, key)}@${IMAGE_FORMAT}`);
|
|
161
|
+
return signed(settings, `/rs:${resize}:${width}:${height}${gravity}/q:${IMAGE_QUALITY}/plain/${sourceOf(settings, key)}@${options.format ?? IMAGE_FORMAT}`);
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
/** A slot's part of the ladder: every rung from `min` to `max`, inclusive. */
|
|
@@ -184,6 +188,7 @@ export interface FluidOptions {
|
|
|
184
188
|
ratio?: number;
|
|
185
189
|
minWidth?: number;
|
|
186
190
|
maxWidth?: number;
|
|
191
|
+
format?: ImageFormat;
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
export interface FixedOptions {
|
|
@@ -192,6 +197,7 @@ export interface FixedOptions {
|
|
|
192
197
|
height?: number;
|
|
193
198
|
fit?: 'cover' | 'contain';
|
|
194
199
|
densities?: readonly number[];
|
|
200
|
+
format?: ImageFormat;
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
const naturalHeight = (image: Pick<RenderImage, 'w' | 'h'>, width: number) => (image.w && image.h ? Math.round((width * image.h) / image.w) : undefined);
|
|
@@ -213,7 +219,9 @@ export function fluidImage(settings: MediaSettings, image: Pick<RenderImage, 'ke
|
|
|
213
219
|
};
|
|
214
220
|
if (!isResizable(image.key)) return { src: imageUrl(settings, image.key), ...dims(image.w ?? undefined) };
|
|
215
221
|
const at = (w: number) =>
|
|
216
|
-
options.ratio
|
|
222
|
+
options.ratio
|
|
223
|
+
? imageUrl(settings, image.key, { width: w, height: Math.round(w / options.ratio), focal: image.focal, format: options.format })
|
|
224
|
+
: imageUrl(settings, image.key, { width: w, format: options.format });
|
|
217
225
|
return {
|
|
218
226
|
src: at(top),
|
|
219
227
|
srcset: widths.map((w) => `${at(w)} ${w}w`).join(', '),
|
|
@@ -231,7 +239,7 @@ export function fixedImage(settings: MediaSettings, image: Pick<RenderImage, 'ke
|
|
|
231
239
|
if (!isResizable(image.key)) return { src: imageUrl(settings, image.key), ...dims };
|
|
232
240
|
const both = !!width && !!height;
|
|
233
241
|
const resize = both && options.fit !== 'contain' ? 'fill' : 'fit';
|
|
234
|
-
const at = (d: number) => imageUrl(settings, image.key, { width: width ? width * d : 0, height: height ? height * d : 0, resize, focal: image.focal });
|
|
242
|
+
const at = (d: number) => imageUrl(settings, image.key, { width: width ? width * d : 0, height: height ? height * d : 0, resize, focal: image.focal, format: options.format });
|
|
235
243
|
const densities = options.densities ?? IMAGE_DENSITIES;
|
|
236
244
|
return { src: at(densities[0] ?? 1), srcset: densities.map((d) => `${at(d)} ${d}x`).join(', '), ...dims };
|
|
237
245
|
}
|
|
@@ -245,3 +253,28 @@ export function brandImageUrl(key: string, cssHeight: number, config: MediaConfi
|
|
|
245
253
|
if (config.kind !== 'ready') return null;
|
|
246
254
|
return imageUrl(config.settings, key, { height: cssHeight * 2 });
|
|
247
255
|
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Urban Return port, Decision 20: one URL for a Site Settings image, for the page's frame
|
|
259
|
+
* (a header, a footer, a layout), which has no block's `cms` for `<Media>`. Exported from
|
|
260
|
+
* `caracms/content`:
|
|
261
|
+
*
|
|
262
|
+
* const settings = await siteSettings(site, Astro.locals);
|
|
263
|
+
* const url = settingsImageUrl(settings.megaPhoto, { width: 640, height: 400 });
|
|
264
|
+
*
|
|
265
|
+
* `value` is the field as `siteSettings` gives it (resolved, M9), or the stored value. The
|
|
266
|
+
* rules are `<Media>`'s: the signed imgproxy URL, `fit` to one side, `fill` to both with the
|
|
267
|
+
* focal point's gravity, a GIF or an SVG passed through unresized, WebP; under
|
|
268
|
+
* `CARACMS_MEDIA=memory` the in-memory media's URL. No size: `fit` at the ladder's top rung.
|
|
269
|
+
* Null for an empty value (or a clip, or a file's key) and when the media isn't set up.
|
|
270
|
+
*/
|
|
271
|
+
export function settingsImageUrl(value: unknown, options: { width?: number; height?: number } = {}): string | null {
|
|
272
|
+
const image = renderMedia(value, { only: 'image' });
|
|
273
|
+
if (!image || image.kind !== 'image') return null;
|
|
274
|
+
const config = readMediaConfig();
|
|
275
|
+
if (config.kind !== 'ready') return null;
|
|
276
|
+
const width = options.width ?? 0;
|
|
277
|
+
const height = options.height ?? 0;
|
|
278
|
+
const sized = width > 0 || height > 0 ? { width, height } : { width: IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1]! };
|
|
279
|
+
return imageUrl(config.settings, image.key, { ...sized, focal: image.focal });
|
|
280
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -140,7 +140,8 @@ export function documentReader(site: Site, source: ContentSource): DocumentReade
|
|
|
140
140
|
let found = lists.get(collection);
|
|
141
141
|
if (!found) {
|
|
142
142
|
found = listDocuments(site, collection, source).then(async (listed) => {
|
|
143
|
-
|
|
143
|
+
// M9 Decision 7: a hidden page isn't on the site, so a listing of its collection leaves it out.
|
|
144
|
+
const read = await Promise.all(listed.filter((e) => !e.warning && !e.hidden).map((e) => reader.document(collection, e.path)));
|
|
144
145
|
return read.filter((r): r is ReadEntry => r !== null);
|
|
145
146
|
});
|
|
146
147
|
lists.set(collection, found);
|
package/src/rich-text.ts
CHANGED
|
@@ -69,12 +69,19 @@ export interface RichTextOptions {
|
|
|
69
69
|
headings: readonly HeadingKind[];
|
|
70
70
|
/** Whether the field allows links. */
|
|
71
71
|
links: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* M9 Decision 11: a heading rich-text field. Paragraphs and bold only: a list's items
|
|
74
|
+
* read as paragraphs (a `disallowed-kind` issue) and italic is dropped (`italic-off`).
|
|
75
|
+
* Such a field also has no headings and no links.
|
|
76
|
+
*/
|
|
77
|
+
heading?: boolean;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/** Something the reader changed or couldn't read, for the field's warning. `block` is the stored entry's index. */
|
|
75
81
|
export type RichTextIssue =
|
|
76
82
|
| { code: 'disallowed-kind'; block: number; kind: string }
|
|
77
83
|
| { code: 'link-off'; block: number }
|
|
84
|
+
| { code: 'italic-off'; block: number }
|
|
78
85
|
| { code: 'unreadable'; block: number };
|
|
79
86
|
|
|
80
87
|
// ── Runs ──────────────────────────────────────────────────────────────────────
|
|
@@ -480,10 +487,12 @@ const LIST_KINDS = new Set<string>(['ul', 'ol']);
|
|
|
480
487
|
const allowedText = (kind: string, options: RichTextOptions): TextKind =>
|
|
481
488
|
kind === 'p' || ((kind === 'h2' || kind === 'h3') && options.headings.includes(kind)) ? (kind as TextKind) : 'p';
|
|
482
489
|
|
|
483
|
-
/** Read one unit, applying `links` (off: hrefs dropped, with a flag). */
|
|
484
|
-
function unit(runs: TextRun[], options: RichTextOptions): { runs: TextRun[]; droppedLink: boolean } {
|
|
490
|
+
/** Read one unit, applying `links` (off: hrefs dropped, with a flag) and a heading field's no italic (M9 Decision 11). */
|
|
491
|
+
function unit(runs: TextRun[], options: RichTextOptions): { runs: TextRun[]; droppedLink: boolean; droppedItalic: boolean } {
|
|
485
492
|
const droppedLink = !options.links && runs.some((r) => r.href !== undefined);
|
|
486
|
-
|
|
493
|
+
const droppedItalic = !!options.heading && runs.some((r) => r.italic);
|
|
494
|
+
const plain = droppedItalic ? runs.map(({ italic: _, ...r }) => r) : runs;
|
|
495
|
+
return { runs: trimRuns(normaliseRuns(plain, { links: options.links })), droppedLink, droppedItalic };
|
|
487
496
|
}
|
|
488
497
|
|
|
489
498
|
/** A plain string as rich text: its characters literal, one paragraph per blank-line chunk, single newlines as breaks. */
|
|
@@ -550,14 +559,17 @@ export function readRichText(raw: unknown, options: RichTextOptions): { value: R
|
|
|
550
559
|
}
|
|
551
560
|
}
|
|
552
561
|
|
|
553
|
-
if (!list && !(TEXT_KINDS.has(kind) && allowedText(kind, options) === kind)) {
|
|
562
|
+
if ((!list || options.heading) && !(TEXT_KINDS.has(kind) && allowedText(kind, options) === kind)) {
|
|
554
563
|
issues.push({ code: 'disallowed-kind', block: index, kind });
|
|
555
564
|
}
|
|
556
565
|
const read = units.map((u) => unit(u, options));
|
|
557
566
|
if (read.some((u) => u.droppedLink)) issues.push({ code: 'link-off', block: index });
|
|
567
|
+
if (read.some((u) => u.droppedItalic)) issues.push({ code: 'italic-off', block: index });
|
|
558
568
|
const kept = read.map((u) => u.runs).filter((runs) => runs.length > 0);
|
|
559
569
|
if (kept.length === 0) return;
|
|
560
|
-
|
|
570
|
+
// M9 Decision 11: a heading field has no lists; each item is a line of its own.
|
|
571
|
+
if (list && options.heading) for (const runs of kept) value.push({ kind: 'p', runs });
|
|
572
|
+
else if (list) value.push({ kind: kind as ListKind, items: kept });
|
|
561
573
|
else value.push({ kind: allowedText(kind, options), runs: kept[0]! });
|
|
562
574
|
});
|
|
563
575
|
return { value, issues };
|
|
@@ -647,6 +659,19 @@ export function inlineHtml(runs: readonly TextRun[], options: RichTextHtmlOption
|
|
|
647
659
|
.join('');
|
|
648
660
|
}
|
|
649
661
|
|
|
662
|
+
/**
|
|
663
|
+
* A heading rich-text field's value as the inside of its heading tag (M9 Decision 11):
|
|
664
|
+
* each paragraph's inline runs, the paragraphs joined by `<br>`; never a `<p>`.
|
|
665
|
+
*/
|
|
666
|
+
export function richHeadingHtml(value: RichTextValue, options: RichTextHtmlOptions = {}): string {
|
|
667
|
+
return value.flatMap((block) => ('runs' in block ? [block.runs] : block.items)).map((runs) => inlineHtml(runs, options)).join('<br>');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** A value's plain text, one line per paragraph or item (breaks inside one as `\n`): what reads a rich heading as text. */
|
|
671
|
+
export function richTextPlain(value: RichTextValue): string {
|
|
672
|
+
return value.flatMap((block) => ('runs' in block ? [block.runs] : block.items)).map(runsText).join('\n');
|
|
673
|
+
}
|
|
674
|
+
|
|
650
675
|
/**
|
|
651
676
|
* A value as semantic HTML with no classes: `<p>`, `<h2>`, `<h3>`, `<ul>`/`<ol>`
|
|
652
677
|
* with `<li>`. A heading with a break renders as Text's multi-line heading does,
|
package/src/serialize.ts
CHANGED
|
@@ -169,6 +169,8 @@ export function applyEdits(base: string | null, edited: SiteDocument, site: Pick
|
|
|
169
169
|
}
|
|
170
170
|
members.push(['blocks', arrayText(blocks, 1)]);
|
|
171
171
|
if (!isEmptySeo(seo)) members.push(['seo', seoText(seo, 1)]);
|
|
172
|
+
// M9 Decision 7: `hidden` right after `title` (where the Tina script writes it).
|
|
173
|
+
if (edited.hidden) members.splice(1, 0, ['hidden', 'true']);
|
|
172
174
|
return `${objectText(members, 0)}\n`;
|
|
173
175
|
}
|
|
174
176
|
|
|
@@ -222,6 +224,9 @@ export function applyEdits(base: string | null, edited: SiteDocument, site: Pick
|
|
|
222
224
|
if (!same(value, parseField(field, raw[key]))) fieldTexts.set(key, fieldText(field, value, 1));
|
|
223
225
|
}
|
|
224
226
|
}
|
|
227
|
+
// M9 Decision 7: `hidden` per edit: absent, untouched; true adds it after `title`; false takes it out.
|
|
228
|
+
const hiddenEdit = edited.hidden === undefined || edited.hidden === (read.hidden === true) ? null : edited.hidden;
|
|
229
|
+
if (hiddenEdit !== null) fieldTexts.set('hidden', 'true');
|
|
225
230
|
if (!titleChanged && !blocksChanged && !seo && fieldTexts.size === 0) return base;
|
|
226
231
|
|
|
227
232
|
const replacements = new Map<string, string>();
|
|
@@ -231,9 +236,13 @@ export function applyEdits(base: string | null, edited: SiteDocument, site: Pick
|
|
|
231
236
|
|
|
232
237
|
let out = replacements.size ? replaceTopLevel(source, root, replacements) : source.text;
|
|
233
238
|
if (seo && !seo.text) out = removeTopLevel(out, 'seo');
|
|
239
|
+
if (hiddenEdit === false) {
|
|
240
|
+
fieldTexts.delete('hidden');
|
|
241
|
+
out = removeTopLevel(out, 'hidden');
|
|
242
|
+
}
|
|
234
243
|
if (fieldTexts.size) {
|
|
235
244
|
const next = parseSource(out);
|
|
236
|
-
if (next.root.kind === 'object') out = spliceKeys(next, next.root, fieldTexts, ['title', ...Object.keys(fields)]);
|
|
245
|
+
if (next.root.kind === 'object') out = spliceKeys(next, next.root, fieldTexts, ['title', 'hidden', ...Object.keys(fields)]);
|
|
237
246
|
}
|
|
238
247
|
return withTrailingNewline(out);
|
|
239
248
|
}
|