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.
Files changed (93) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +65 -20
  6. package/bin/templates/catch-all.astro.txt +3 -2
  7. package/package.json +1 -1
  8. package/skills/caracms-adopt/SKILL.md +17 -3
  9. package/skills/caracms-adopt/look-same.mjs +24 -11
  10. package/skills/caracms-blocks/SKILL.md +69 -4
  11. package/skills/caracms-content/SKILL.md +2 -1
  12. package/skills/caracms-tina/SKILL.md +431 -0
  13. package/skills/caracms-tina/field-kinds.md +88 -0
  14. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  15. package/src/block-runs.ts +84 -0
  16. package/src/cli/validate.ts +10 -5
  17. package/src/components/BlockRunTree.astro +45 -0
  18. package/src/components/Blocks.astro +20 -1
  19. package/src/components/Form.astro +76 -10
  20. package/src/components/Head.astro +4 -2
  21. package/src/components/Media.astro +27 -1
  22. package/src/components/RichText.astro +12 -4
  23. package/src/components/Text.astro +32 -9
  24. package/src/components/form-client.ts +7 -4
  25. package/src/components/sitemap.xml.ts +2 -1
  26. package/src/config.ts +32 -1
  27. package/src/content-schema.ts +7 -4
  28. package/src/content.ts +28 -0
  29. package/src/document.ts +15 -2
  30. package/src/editor/client/block-list/summary.ts +7 -1
  31. package/src/editor/client/editor.ts +24 -6
  32. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  33. package/src/editor/client/fields/LinkField.tsx +17 -12
  34. package/src/editor/client/fields/ReferenceField.tsx +6 -0
  35. package/src/editor/client/fields/RichTextField.tsx +38 -32
  36. package/src/editor/client/fields/host.tsx +5 -0
  37. package/src/editor/client/fields/parts.tsx +2 -1
  38. package/src/editor/client/fields/rich-text-doc.ts +28 -2
  39. package/src/editor/client/fields/rich-text-schema.ts +41 -2
  40. package/src/editor/client/live/publish.tsx +20 -10
  41. package/src/editor/client/nav/DocRow.tsx +6 -0
  42. package/src/editor/client/overlays/rich-text-bar.ts +6 -6
  43. package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
  44. package/src/editor/client/pane/parts.tsx +34 -2
  45. package/src/editor/client/preview/driver.ts +5 -5
  46. package/src/editor/client/preview/field-text.ts +40 -2
  47. package/src/editor/client/preview/page-edit.ts +2 -2
  48. package/src/editor/client/preview/page-editor-registry.ts +4 -3
  49. package/src/editor/client/preview/page-editor.ts +3 -3
  50. package/src/editor/client/state/document-store.ts +20 -2
  51. package/src/editor/client/state/keeper.ts +51 -6
  52. package/src/editor/client/state/live.ts +45 -6
  53. package/src/editor/middleware.ts +3 -2
  54. package/src/editor/routes/try-again.ts +2 -1
  55. package/src/editor/server/admin-props.ts +2 -1
  56. package/src/editor/server/mode.ts +13 -3
  57. package/src/editor/styles/editor.css +2 -0
  58. package/src/editor/styles/levels/live.css +1 -1
  59. package/src/editor/types.ts +27 -6
  60. package/src/fields.ts +63 -15
  61. package/src/files-route.ts +9 -4
  62. package/src/forms/endpoint.ts +11 -16
  63. package/src/forms/rules.ts +9 -1
  64. package/src/head.ts +17 -4
  65. package/src/index.ts +53 -7
  66. package/src/links.ts +35 -0
  67. package/src/live/README.md +5 -2
  68. package/src/live/auth/auth.ts +4 -1
  69. package/src/live/auth/guard.ts +3 -2
  70. package/src/live/auth/index.ts +6 -7
  71. package/src/live/auth/login.ts +17 -8
  72. package/src/live/auth/middleware.ts +7 -3
  73. package/src/live/auth/request.ts +92 -11
  74. package/src/live/auth/views.ts +9 -4
  75. package/src/live/autosaves.ts +59 -16
  76. package/src/live/backend.ts +54 -24
  77. package/src/live/branches.ts +19 -0
  78. package/src/live/drafts-store.ts +4 -3
  79. package/src/live/editor.ts +92 -67
  80. package/src/live/git.ts +112 -34
  81. package/src/live/github.ts +128 -13
  82. package/src/live/locks.ts +92 -26
  83. package/src/live/mode.ts +177 -18
  84. package/src/live/notices.ts +100 -0
  85. package/src/live/redis.ts +161 -39
  86. package/src/live/runtime.ts +15 -4
  87. package/src/live/status-cache.ts +219 -0
  88. package/src/live/status.ts +14 -2
  89. package/src/live/timings.ts +24 -0
  90. package/src/media.ts +40 -7
  91. package/src/read.ts +2 -1
  92. package/src/rich-text.ts +30 -5
  93. package/src/serialize.ts +10 -1
@@ -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 { appId, privateKey, owner, repo } = settings.github;
72
- backend = new GitHubBackend(new GitHubClient({ appId, privateKey, owner, repo, now }, tracker));
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
- railway: settings.railway ? { ...settings.railway, fetch: options.railwayFetch } : null,
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
+ }
@@ -62,7 +62,11 @@ export interface PublishRecord {
62
62
  tryAgainAt: string | null;
63
63
  }
64
64
 
65
- export type PublishState = 'publishing' | 'live' | 'failed';
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 (RAILWAY_GIT_COMMIT_SHA / CARACMS_RUNNING_COMMIT), or null with no deploy pipeline. */
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
  }
@@ -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 { RenderImage } from './media-value.ts';
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 ? imageUrl(settings, image.key, { width: w, height: Math.round(w / options.ratio), focal: image.focal }) : imageUrl(settings, image.key, { width: w });
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
- const read = await Promise.all(listed.filter((e) => !e.warning).map((e) => reader.document(collection, e.path)));
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
- return { runs: trimRuns(normaliseRuns(runs, { links: options.links })), droppedLink };
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
- if (list) value.push({ kind: kind as ListKind, items: kept });
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
  }