caracms 0.2.0 → 0.3.1
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 +64 -20
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +5 -3
- package/skills/caracms-adopt/look-same.mjs +8 -4
- package/skills/caracms-blocks/SKILL.md +20 -2
- package/skills/caracms-tina/SKILL.md +14 -10
- package/src/components/Head.astro +3 -2
- package/src/content.ts +2 -0
- package/src/editor/client/documents/actions.ts +6 -4
- package/src/editor/client/documents/dialogs.tsx +2 -1
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/pane/parts.tsx +34 -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/levels/live.css +1 -1
- package/src/editor/types.ts +25 -6
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/head.ts +10 -1
- package/src/index.ts +53 -7
- package/src/live/README.md +4 -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 +13 -1
- 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 +131 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +11 -3
- 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 +26 -1
|
@@ -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
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { createHmac } from 'node:crypto';
|
|
27
27
|
import type { ImageFormat } from './fields.ts';
|
|
28
|
-
import type
|
|
28
|
+
import { renderMedia, type RenderImage } from './media-value.ts';
|
|
29
29
|
|
|
30
30
|
/** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
|
|
31
31
|
export const IMAGE_WIDTHS: readonly number[] = [160, 320, 480, 640, 960, 1280, 1920];
|
|
@@ -253,3 +253,28 @@ export function brandImageUrl(key: string, cssHeight: number, config: MediaConfi
|
|
|
253
253
|
if (config.kind !== 'ready') return null;
|
|
254
254
|
return imageUrl(config.settings, key, { height: cssHeight * 2 });
|
|
255
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
|
+
}
|