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
@@ -2,8 +2,11 @@
2
2
  * Keeping: getting the editor's copy of each document to the server (M3 Decisions 5
3
3
  * and 6; M4 Decisions 4, 9 and 11).
4
4
  *
5
- * - **About a second after the last change**, the whole document is POSTed to the
6
- * write route with the base it was made against: `{ file, base, document }`
5
+ * - **About a second after the last change** (local mode), the whole document is POSTed to the
6
+ * write route with the base it was made against: `{ file, base, document }`. Live mode
7
+ * (Redis diet Decision 10, `holds`): at once while the tab doesn't hold the document's
8
+ * lock and no write for it is in flight; otherwise 3 s after the last change, at most
9
+ * 15 s after the first change not yet sent
7
10
  * (server/write.ts). One write per document in flight; a change made meanwhile
8
11
  * goes in the next one. In live mode every write carries the tab's id
9
12
  * (`X-Caracms-Tab`) and its place in the tab's order (`X-Caracms-Seq`, rising, from
@@ -116,7 +119,7 @@ export interface KeeperOptions {
116
119
  storage: KeeperStorage | null;
117
120
  /** Storage keys are `caracms:unsent:<storageKey>:<docId>`, so two sites on one dev port don't mix. */
118
121
  storageKey: string;
119
- /** ms after the last change. Default 1000. */
122
+ /** ms after the last change. Default 1000 (local mode; live mode's timing is `holds`'s, below). */
120
123
  delay?: number;
121
124
  setTimer?: (fn: () => void, ms: number) => unknown;
122
125
  clearTimer?: (timer: unknown) => void;
@@ -145,6 +148,20 @@ export interface KeeperOptions {
145
148
  onSignedOut?: (body: SignedOutError) => void;
146
149
  /** Who is signed in, for the re-send rule ("your own commits"). */
147
150
  you?: () => Person | null;
151
+ /**
152
+ * Redis diet Decision 10: whether this tab holds the document's lock (a write for it
153
+ * was Kept, or a poll says so). Given, live mode's timing applies: a change goes at
154
+ * once while the document isn't held and no write for it is in flight (that write
155
+ * takes the lock); otherwise `keepDelay` after the last change, and never later than
156
+ * `keepCap` after the first change not yet sent. Absent: `delay` after the last change.
157
+ */
158
+ holds?: (file: string) => boolean;
159
+ /** Decision 10: ms after the last change, once held. Default 3000. */
160
+ keepDelay?: number;
161
+ /** Decision 10: …and at most this long after the first change not yet sent. Default 15000. */
162
+ keepCap?: number;
163
+ /** A write for this file was Kept (the server has it, and the write took or kept the lock). */
164
+ onKept?: (file: string) => void;
148
165
  }
149
166
 
150
167
  /** A held record the editor may need to look at again: once the lock lapses, or the session is back. */
@@ -223,6 +240,9 @@ interface Record {
223
240
  by: Person | null;
224
241
  elsewhere: boolean;
225
242
  timer: unknown;
243
+ /** Live timing: when the last change came, and the first change not yet sent (null: none). */
244
+ lastChange: number;
245
+ firstUnsent: number | null;
226
246
  }
227
247
 
228
248
  const PREFIX = 'caracms:unsent:';
@@ -309,6 +329,7 @@ export function createKeeper(options: KeeperOptions): Keeper {
309
329
  const clearTimer = options.clearTimer ?? ((t) => clearTimeout(t as ReturnType<typeof setTimeout>));
310
330
  const log = options.log ?? ((message) => console.warn(`[caracms] ${message}`));
311
331
  const now = options.now ?? Date.now;
332
+ const live = options.holds ? { holds: options.holds, delay: options.keepDelay ?? 3000, cap: options.keepCap ?? 15_000 } : null;
312
333
  const prefix = `${PREFIX}${options.storageKey}:`;
313
334
  const records = new Map<string, Record>();
314
335
  let offline = false;
@@ -394,7 +415,7 @@ export function createKeeper(options: KeeperOptions): Keeper {
394
415
  const record = (id: string, file: string, fileVersion: string): Record => {
395
416
  let r = records.get(id);
396
417
  if (!r) {
397
- r = { id, file, base: '', document: null, fileVersion, revision: 0, written: 0, inFlight: false, failure: null, refusal: null, by: null, elsewhere: false, timer: null };
418
+ r = { id, file, base: '', document: null, fileVersion, revision: 0, written: 0, inFlight: false, failure: null, refusal: null, by: null, elsewhere: false, timer: null, lastChange: 0, firstUnsent: null };
398
419
  records.set(id, r);
399
420
  }
400
421
  return r;
@@ -402,6 +423,12 @@ export function createKeeper(options: KeeperOptions): Keeper {
402
423
  const stash = (r: Record) => {
403
424
  if (r.document) storage.set(r.id, { v: 1, file: r.file, base: r.base, document: r.document, fileVersion: r.fileVersion });
404
425
  };
426
+ /** Decision 10 (live): ms until the change waiting goes, once the document is held: the delay after the last change, capped from the first unsent one. */
427
+ const due = (r: Record) => {
428
+ if (!live) return delay;
429
+ const at = Math.min(r.lastChange + live.delay, (r.firstUnsent ?? r.lastChange) + live.cap);
430
+ return Math.max(0, at - now());
431
+ };
405
432
  const schedule = (r: Record, ms = delay) => {
406
433
  if (r.timer !== null) clearTimer(r.timer);
407
434
  r.timer = setTimer(() => {
@@ -447,6 +474,9 @@ export function createKeeper(options: KeeperOptions): Keeper {
447
474
  if (!dirty(r) || !r.document) return;
448
475
  if (r.inFlight) return; // the change waits; it's sent when this write returns
449
476
  const revision = r.revision;
477
+ // Every change so far goes in this write; the cap counts again from the next one.
478
+ const firstUnsent = r.firstUnsent;
479
+ r.firstUnsent = null;
450
480
  r.inFlight = true;
451
481
  refresh();
452
482
  let ok = false;
@@ -470,6 +500,7 @@ export function createKeeper(options: KeeperOptions): Keeper {
470
500
  r.elsewhere = false;
471
501
  if (typeof body.version === 'string') r.fileVersion = body.version;
472
502
  r.written = Math.max(r.written, revision);
503
+ options.onKept?.(r.file);
473
504
  if (dirty(r)) stash(r); // newer edits wait: they're now against the version just written
474
505
  else storage.remove(r.id);
475
506
  } catch (error) {
@@ -505,6 +536,8 @@ export function createKeeper(options: KeeperOptions): Keeper {
505
536
  }
506
537
  } finally {
507
538
  r.inFlight = false;
539
+ // Not sent after all: the cap still counts from the earliest change waiting.
540
+ if (!ok && firstUnsent !== null) r.firstUnsent = Math.min(firstUnsent, r.firstUnsent ?? firstUnsent);
508
541
  if (ok && !offline) {
509
542
  stopRetry();
510
543
  // Live mode: the server is back, so whatever else couldn't reach it goes now.
@@ -514,7 +547,12 @@ export function createKeeper(options: KeeperOptions): Keeper {
514
547
  refresh();
515
548
  }
516
549
  // A change that came in during the write goes out now, unless its own timer is still running.
517
- if (ok && dirty(r) && r.timer === null && !r.failure) void send(r);
550
+ // Live mode (Decision 10): it waits by the same rule as any change to a held document.
551
+ if (ok && dirty(r) && r.timer === null && !r.failure) {
552
+ const ms = live ? due(r) : 0;
553
+ if (ms > 0) schedule(r, ms);
554
+ else void send(r);
555
+ }
518
556
  }
519
557
 
520
558
  function adopt(id: string, stored: StoredUnsent): Record {
@@ -561,8 +599,15 @@ export function createKeeper(options: KeeperOptions): Keeper {
561
599
  r.base = edit.base;
562
600
  r.document = edit.document;
563
601
  r.revision += 1;
602
+ const t = now();
603
+ r.lastChange = t;
604
+ if (r.firstUnsent === null) r.firstUnsent = t;
564
605
  stash(r);
565
- if (!r.failure || !WAITS.has(r.failure)) schedule(r);
606
+ if (!r.failure || !WAITS.has(r.failure)) {
607
+ // Decision 10: the first write to a document goes at once, since it's what takes the lock.
608
+ if (live && !r.inFlight && !live.holds(r.file)) schedule(r, 0);
609
+ else schedule(r, due(r));
610
+ }
566
611
  // After a failed write (unreachable or refused), the next change retries everything waiting.
567
612
  for (const other of records.values()) if (other !== r && other.failure && !WAITS.has(other.failure) && dirty(other)) schedule(other, 0);
568
613
  refresh();
@@ -6,6 +6,12 @@
6
6
  * - **The poll**: `GET /_caracms/status?file=<the open document>` every `POLL_MS`
7
7
  * while the tab is visible, and at once when it becomes visible again. It carries
8
8
  * the tab's id, so it's also the lock's heartbeat and the tab's presence.
9
+ * - **Idle tabs slow down** (Redis diet Decision 8): a visible tab with no input for
10
+ * `idleSlowAfterMs` polls every `slowPollMs`; any input, becoming visible, or a poll
11
+ * answer showing someone else's lock on the open document (another person or another
12
+ * tab) brings it back to `pollMs` at once. Each poll says how long until the next
13
+ * (`interval`), so the server holds the tab's presence that long (Decision 9); the
14
+ * next poll never comes later than the interval it sent.
9
15
  * - **Last-known data**: a poll that can't reach the server (a deploy replacing it,
10
16
  * which Publish causes) keeps the last status and says `reachable: false`. Nothing
11
17
  * is lost and nothing shows as an error; the next answer brings it back.
@@ -22,6 +28,10 @@ import { createStore, type Store } from './store.ts';
22
28
  */
23
29
  export const POLL_MS = 5000;
24
30
 
31
+ /** Redis diet Decision 8's defaults, for a page whose props don't carry the live timings. */
32
+ export const IDLE_SLOW_AFTER_MS = 2 * 60_000;
33
+ export const SLOW_POLL_MS = 30_000;
34
+
25
35
  /** Where sign out goes (Phase 3's route): `{ ok, location }` to a JSON request. */
26
36
  export const SIGN_OUT = '/admin/login/signout';
27
37
 
@@ -98,6 +108,25 @@ export function createLiveSession(options: LiveOptions): LiveSession {
98
108
  let lastInput = now();
99
109
  let inFlight: Promise<void> | null = null;
100
110
  let again = false;
111
+ const pollMs = options.props.pollMs ?? POLL_MS;
112
+ const slowPollMs = options.props.slowPollMs ?? SLOW_POLL_MS;
113
+ const idleSlowAfterMs = options.props.idleSlowAfterMs ?? IDLE_SLOW_AFTER_MS;
114
+ /** The interval the last poll sent: the next one comes no later (the server holds presence from it). */
115
+ let sentInterval = pollMs;
116
+ /** Set by start(): input while the loop runs slow brings it back to `pollMs` at once. */
117
+ let onInput: (() => void) | null = null;
118
+ const visible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
119
+ /** Decision 8: the open document's lock is someone else's (another person, or another tab). */
120
+ const othersLock = () => {
121
+ const lock = state.get().status?.open?.lock;
122
+ return !!lock && lock.mine !== 'this-tab';
123
+ };
124
+ /** How long until the next poll, as things stand. */
125
+ const interval = () => (visible() && now() - lastInput >= idleSlowAfterMs && !othersLock() ? slowPollMs : pollMs);
126
+ const used = () => {
127
+ lastInput = now();
128
+ onInput?.();
129
+ };
101
130
 
102
131
  const headers = (json: boolean): Record<string, string> => ({ Accept: 'application/json', [TAB_HEADER]: options.tab, ...(json ? { 'Content-Type': 'application/json' } : {}) });
103
132
 
@@ -125,7 +154,9 @@ export function createLiveSession(options: LiveOptions): LiveSession {
125
154
  const mine = ++seq;
126
155
  const file = options.openFile();
127
156
  // `input`: ms since someone used this tab. The server stops renewing an idle tab's lock.
128
- const query = new URLSearchParams({ ...(file ? { file } : {}), input: String(Math.max(0, Math.round(now() - lastInput))) });
157
+ // `interval`: ms until this tab's next poll (Redis diet Contracts): the server holds its presence from it.
158
+ sentInterval = interval();
159
+ const query = new URLSearchParams({ ...(file ? { file } : {}), input: String(Math.max(0, Math.round(now() - lastInput))), interval: String(sentInterval) });
129
160
  const answer = await call<StatusResponse>(`${ENDPOINTS.status}?${query}`);
130
161
  if (!answer) {
131
162
  state.update({ reachable: false });
@@ -169,17 +200,22 @@ export function createLiveSession(options: LiveOptions): LiveSession {
169
200
  let stopped = false;
170
201
  /** One loop at a time: a restart starts a new generation, and the old one stops at its next turn. */
171
202
  let generation = 0;
172
- const visible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
203
+ /** Whether the timer now running is a slow one (input then restarts at once). */
204
+ let slow = false;
173
205
  const loop = (g: number) => {
174
206
  if (stopped || g !== generation) return;
207
+ // Never later than the last poll said; sooner when the answer showed someone else's lock.
208
+ const ms = Math.min(sentInterval, interval());
209
+ slow = ms > pollMs;
175
210
  timer = setTimer(async () => {
176
211
  timer = null;
177
212
  if (g !== generation) return;
178
213
  if (visible()) await poll();
179
214
  loop(g);
180
- }, options.props.pollMs ?? POLL_MS);
215
+ }, ms);
181
216
  };
182
217
  const restart = () => {
218
+ slow = false;
183
219
  const g = ++generation;
184
220
  if (timer !== null) clearTimer(timer);
185
221
  timer = null;
@@ -191,14 +227,17 @@ export function createLiveSession(options: LiveOptions): LiveSession {
191
227
  lastInput = now(); // coming back to the tab counts as using it
192
228
  restart();
193
229
  };
194
- const onActivity = () => {
195
- lastInput = now();
230
+ const onActivity = () => used();
231
+ // Decision 8: input on a slowed tab polls now, and the loop is back to `pollMs`.
232
+ onInput = () => {
233
+ if (slow && !stopped) restart();
196
234
  };
197
235
  document.addEventListener('visibilitychange', onVisible);
198
236
  for (const type of ACTIVITY_EVENTS) document.addEventListener(type, onActivity, { capture: true, passive: true });
199
237
  restart();
200
238
  return () => {
201
239
  stopped = true;
240
+ onInput = null;
202
241
  if (timer !== null) clearTimer(timer);
203
242
  document.removeEventListener('visibilitychange', onVisible);
204
243
  for (const type of ACTIVITY_EVENTS) document.removeEventListener(type, onActivity, { capture: true });
@@ -231,7 +270,7 @@ export function createLiveSession(options: LiveOptions): LiveSession {
231
270
  await call(ENDPOINTS.notice, { method: 'POST', body: { id } });
232
271
  },
233
272
  active() {
234
- lastInput = now();
273
+ used();
235
274
  },
236
275
  async takeOver(file) {
237
276
  const answer = await call<LockResponse>(ENDPOINTS.lock, { method: 'POST', body: { file, action: 'take-over' } });
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Registered first (`addMiddleware({ order: 'pre' })` in ../index.ts), so it runs
7
7
  * before Phase 3's sign-in guard, which then only ever sees local or live mode.
8
- * Every other path passes straight through, and so does the public form endpoint,
8
+ * Every other path passes straight through, and so does a POST to the public form endpoint,
9
9
  * `/_caracms/form` (M6 Decision 5): the form is part of the public site, which works
10
10
  * without the editor.
11
11
  *
@@ -39,7 +39,8 @@ export const isEditorPath = (pathname: string) =>
39
39
  (pathname === '/admin' || pathname.startsWith('/admin/') || pathname.startsWith('/_caracms/')) && !isFormEndpoint(pathname);
40
40
 
41
41
  export const onRequest: MiddlewareHandler = (context, next) => {
42
- if (!isEditorPath(context.url.pathname)) return next();
42
+ // The form endpoint's POST is public; any other method to it is as unavailable as the rest (guard.ts's let-through is POST-only).
43
+ if (!isEditorPath(context.url.pathname) && !(isFormEndpoint(context.url.pathname) && context.request.method !== 'POST')) return next();
43
44
  const mode = editorMode();
44
45
  if (mode.kind !== 'unconfigured') {
45
46
  if (context.url.pathname !== ENDPOINTS.preview || context.request.method !== 'GET') return next();
@@ -1,5 +1,6 @@
1
1
  /**
2
- * POST /_caracms/try-again: redeploy a failed publish through Railway (Decision 12).
2
+ * POST /_caracms/try-again: redeploy a failed (or slow) publish through the redeployer:
3
+ * Railway's API or a deploy hook (M4 Decision 12; runs-anywhere Decision 11).
3
4
  * Live mode only (M4 Phase 2); the handler is ../../live/editor.ts → `tryAgain`. Shapes: ../types.ts.
4
5
  */
5
6
  import { liveRoute } from './_live.ts';
@@ -109,7 +109,8 @@ async function liveProps(site: ResolvedSite, url: URL, file: string, person: Per
109
109
  } catch (error) {
110
110
  console.warn(`[caracms] /admin couldn't read the status for its first paint (${error instanceof Error ? error.message : String(error)}); the first poll fills it`);
111
111
  }
112
- return { you: person, status, pollMs: mode.settings.timings.pollMs };
112
+ const t = mode.settings.timings;
113
+ return { you: person, status, pollMs: t.pollMs, idleSlowAfterMs: t.idleSlowAfterMs, slowPollMs: t.slowPollMs, keepDelayMs: t.keepDelayMs, keepCapMs: t.keepCapMs };
113
114
  }
114
115
 
115
116
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The editor's mode at run time (M4 Decision 1), and the drafts store it implies.
3
3
  *
4
- * - `editorMode()`: `readMode(process.env, isProductionServer())`, read once per
4
+ * - `editorMode()`: `readMode(process.env, isProductionServer(), bakedAtBuild())`, read once per
5
5
  * process. **A production server is the build**, not `NODE_ENV`: the integration
6
6
  * sets `__CARACMS_PRODUCTION__` (a Vite define) to `command !== 'dev'`, so the
7
7
  * built server is always live mode (or unconfigured), and `astro dev` is local
@@ -14,16 +14,26 @@
14
14
  */
15
15
  import type { ResolvedSite } from '../../config.ts';
16
16
  import { liveEditor, type LiveEditor } from '../../live/editor.ts';
17
- import { readMode, unconfiguredMessage, type Mode } from '../../live/mode.ts';
17
+ import { readMode, unconfiguredMessage, type Baked, type Mode } from '../../live/mode.ts';
18
18
  import { localDraftsStore, type DraftsStore } from './drafts-store.ts';
19
19
 
20
20
  declare const __CARACMS_PRODUCTION__: boolean | undefined;
21
+ declare const __CARACMS_SITE__: string | null | undefined;
22
+ declare const __CARACMS_BUILD_COMMIT__: string | null | undefined;
21
23
 
22
24
  /** True in the built server (`astro build`), false under `astro dev` and in plain Node (tests). */
23
25
  export function isProductionServer(): boolean {
24
26
  return typeof __CARACMS_PRODUCTION__ === 'boolean' ? __CARACMS_PRODUCTION__ : false;
25
27
  }
26
28
 
29
+ /** The site's address and the commit the build baked in (runs-anywhere Decisions 7 and 9); empty in plain Node (tests). */
30
+ export function bakedAtBuild(): Baked {
31
+ return {
32
+ site: typeof __CARACMS_SITE__ === 'string' ? __CARACMS_SITE__ : null,
33
+ commit: typeof __CARACMS_BUILD_COMMIT__ === 'string' ? __CARACMS_BUILD_COMMIT__ : null,
34
+ };
35
+ }
36
+
27
37
  const SHARED = Symbol.for('caracms.editorMode');
28
38
  type Shared = { [SHARED]?: Mode };
29
39
 
@@ -31,7 +41,7 @@ type Shared = { [SHARED]?: Mode };
31
41
  export function editorMode(): Mode {
32
42
  const g = globalThis as Shared;
33
43
  if (!g[SHARED]) {
34
- const mode = readMode(process.env, isProductionServer());
44
+ const mode = readMode(process.env, isProductionServer(), bakedAtBuild());
35
45
  if (mode.kind === 'unconfigured') console.error(`[caracms] ${unconfiguredMessage(mode)} The public site is served; /admin answers 503.`);
36
46
  g[SHARED] = mode;
37
47
  }
@@ -215,6 +215,8 @@ details.fgroup[open]>summary .i{rotate:90deg}
215
215
  details.fgroup>.in{padding:0 var(--space-4) var(--space-2)}
216
216
  details.fgroup>summary .wmark{margin-left:var(--space-1)}
217
217
  details.fgroup>summary .wmark .i{margin-left:0;rotate:none}
218
+ /* M9 Decision 7: the page's "Hidden from the site" switch, between its page fields and Search and sharing. */
219
+ .page-hidden{margin-top:var(--space-4)}
218
220
  /* A group field (group()): Page details' section, among a form's fields; inside a list item it keeps to the item's width. */
219
221
  details.fgroup.group-field{margin-block:var(--space-3)}
220
222
  details.fgroup.group-field+details.fgroup.group-field{margin-top:calc(-1 * var(--space-3))}
@@ -6,5 +6,5 @@
6
6
  .mk{display:contents}
7
7
  fieldset.ro-fields{display:contents;margin:0;padding:0;border:0;min-width:0}
8
8
  .note .acts .why{flex-basis:100%;margin:0;font-size:var(--text-xs);color:var(--text-subtle)}
9
- /* Failed with no Railway token (a state M4 adds): Publish is off, so it takes the off ink, not the failure's red (1.9:1 on the off fill). */
9
+ /* Failed (or taking longer than usual) with no redeployer (a state M4 adds): Publish is off, so it takes the off ink, not the failure's red (1.9:1 on the off fill). */
10
10
  .pub.fail .pb.off{color:var(--bar-off-text)}
@@ -205,6 +205,14 @@ export interface LiveProps {
205
205
  status: StatusResponse | null;
206
206
  /** How often the tab polls (live timings' `pollMs`: POLL_MS, shorter only in tests). */
207
207
  pollMs?: number;
208
+ /** Redis diet Decision 8: a visible tab with no input this long polls at `slowPollMs` (live timings'; absent: 2 minutes). */
209
+ idleSlowAfterMs?: number;
210
+ /** Redis diet Decision 8: an idle tab's poll interval (absent: 30 s). */
211
+ slowPollMs?: number;
212
+ /** Redis diet Decision 10: once the tab holds the document, a keep goes this long after the last change (absent: 3 s). */
213
+ keepDelayMs?: number;
214
+ /** Redis diet Decision 10: …and never later than this after the first change not yet sent (absent: 15 s). */
215
+ keepCapMs?: number;
208
216
  }
209
217
 
210
218
  /** A block in the editor: a DocumentBlock plus a key that stays with it through moves (never written). */
@@ -235,6 +243,8 @@ export interface EditorDocument {
235
243
  * has page fields. Set through `setField(PAGE_SCOPE, …)` (document-store.ts).
236
244
  */
237
245
  data?: Record<string, unknown>;
246
+ /** M9 Decision 7: a section builder hidden from the site; absent when the file isn't. Set through `setHidden`. */
247
+ hidden?: boolean;
238
248
  }
239
249
 
240
250
  /** The preview widths (M2 Decision 3): each iframe at its true width. */
@@ -429,17 +439,21 @@ export interface PublishRecordView {
429
439
  }
430
440
 
431
441
  export interface PublishView {
432
- /** `none`: nothing published since the record began. */
433
- state: 'none' | 'publishing' | 'live' | 'failed';
442
+ /**
443
+ * `none`: nothing published since the record began. `slow` (runs-anywhere Decision 10):
444
+ * still not live 15 minutes after it was made or last tried again, with no failure
445
+ * known: "taking longer than usual. It may have failed."
446
+ */
447
+ state: 'none' | 'publishing' | 'live' | 'failed' | 'slow';
434
448
  /** A Publish is running now (committing, or moving the refs), before any record. */
435
449
  running: boolean;
436
450
  record: PublishRecordView | null;
437
451
  /** Live only because there's no deploy pipeline (Decision 12). */
438
452
  assumed: boolean;
439
- /** Failed, and Try again can redeploy. */
453
+ /** Failed or slow, and Try again can redeploy. */
440
454
  canTryAgain: boolean;
441
- /** CARACMS_RAILWAY_TOKEN is set. Failed with no token: "a developer can redeploy from Railway". */
442
- hasToken: boolean;
455
+ /** A redeployer is set (a deploy hook, or Railway's token). Failed with none: "A developer can redeploy it" (runs-anywhere Decision 11). */
456
+ hasRedeployer: boolean;
443
457
  }
444
458
 
445
459
  /** GET /_caracms/status?file=<open document's file> (Decision 13). */
@@ -468,6 +482,13 @@ export interface StatusResponse {
468
482
  presence: PresenceView[];
469
483
  notices: NoticeView[];
470
484
  publish: PublishView;
485
+ /**
486
+ * Runs-anywhere Decision 5: what's wrong with the site's GitHub access, for every
487
+ * person, while it holds (not dismissable, unlike `notices`): a refused credential, a
488
+ * missing permission, or a token expiring within 14 days. `message` names the fix,
489
+ * for the developer it's forwarded to. Null when nothing is wrong.
490
+ */
491
+ githubAccess: { kind: 'refused' | 'permission' | 'expiring'; title: string; message: string } | null;
471
492
  /**
472
493
  * The brand as committed in Site Settings (M5a Decision 10: read from `drafts`, never
473
494
  * an autosave), so the chrome picks up a change on the next poll with no reload.
@@ -498,7 +519,7 @@ export interface HistoryEntryView {
498
519
  at: string;
499
520
  titles: string[];
500
521
  files: string[];
501
- state: 'publishing' | 'live' | 'failed';
522
+ state: 'publishing' | 'live' | 'failed' | 'slow';
502
523
  }
503
524
  /** GET /_caracms/history. */
504
525
  export interface HistoryResponse {
package/src/fields.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * text() one line
9
9
  * text({ multiline: true }) several lines; a break is stored as `\n` (the line-break rule)
10
+ * text({ lines: true }) several lines, each drawn as its own element (M9 Decision 10)
10
11
  * text({ max: 200 }) M6: a soft limit, counted in the editor and amber past it (nothing refused)
11
12
  * lockup({ style }) display text set as lines, optionally with phone lines (Decisions 10–11)
12
13
  * select({ options }) one of a fixed list
@@ -24,7 +25,7 @@
24
25
  *
25
26
  * M5b (Decision 3) adds:
26
27
  *
27
- * media({ only?, play? }) an image `{ key, alt, … }` or a Mux clip `{ video, play, … }` (media-value.ts)
28
+ * media({ only?, play?, format? }) an image `{ key, alt, … }` or a Mux clip `{ video, play, … }` (media-value.ts)
28
29
  *
29
30
  * M6c adds `help` (G8: plain text under the field in the sidebar) to every helper, and
30
31
  * `link({ addressOnly: true })` (G9): `{ "href" }`, a link with no words of its own.
@@ -47,9 +48,9 @@
47
48
  */
48
49
  import { fileAddress, fileKeyOf, fileTypeOf, isFileKey } from './files.ts';
49
50
  import { codeRouteAt, type CodeRoute } from './code-routes.ts';
50
- import { internalLinkPath, isLinkHref, missingLinkTarget, validateLink } from './links.ts';
51
+ import { hiddenLinkTarget, hiddenReferenceTarget, internalLinkPath, isLinkHref, missingLinkTarget, validateLink } from './links.ts';
51
52
  import { MISSING_ALT_MESSAGE, mediaKind, mediaToJson, parseMedia, renderMedia, type MediaOnly, type MediaValue, type RenderMedia } from './media-value.ts';
52
- import { readRichText, richTextHrefs, serializeRichText, type HeadingKind, type RichTextValue } from './rich-text.ts';
53
+ import { readRichText, richTextHrefs, serializeRichText, type HeadingKind, type RichTextOptions, type RichTextValue } from './rich-text.ts';
53
54
 
54
55
  export type { RichTextValue } from './rich-text.ts';
55
56
  export type { ClipValue, ImageValue, MediaOnly, MediaValue, PlayAs, RenderClip, RenderImage, RenderMedia } from './media-value.ts';
@@ -80,7 +81,12 @@ export interface TextField extends FieldBase {
80
81
  * M6: a soft limit (the Form block's intro, Decision 17): the editor counts the
81
82
  * characters and turns amber past it. Nothing is refused: it's kept as written.
82
83
  */
83
- max?: number;
84
+ max?: number; /**
85
+ * M9 Decision 10: the lines rendering. Each typed line draws as its own element (Text's
86
+ * `lineClass`), blank lines draw nothing, and a single line draws unwrapped. Always
87
+ * multiline: a break is still stored as `\n`.
88
+ */
89
+ lines?: true;
84
90
  }
85
91
 
86
92
  export interface LockupField extends FieldBase {
@@ -108,6 +114,12 @@ export interface RichTextField extends FieldBase {
108
114
  headings: readonly HeadingKind[];
109
115
  /** Links allowed (default true). */
110
116
  links: boolean;
117
+ /**
118
+ * M9 Decision 11: the block's heading, typed on the page with bold and line breaks only.
119
+ * It can hold the page's `h1` (M3 Decision 16) and draws inside its heading tag, its
120
+ * paragraphs as lines joined by `<br>`. No headings, no links, no lists, no italic.
121
+ */
122
+ heading?: true;
111
123
  }
112
124
 
113
125
  export interface LinkField extends FieldBase {
@@ -200,14 +212,19 @@ export interface ReferenceField extends FieldBase {
200
212
  /**
201
213
  * A media slot (M5b Decision 3): an image or a Mux clip. `only` limits it to one
202
214
  * kind (a value of the other is kept and warned); `play: 'loop'` makes it loop-only
203
- * (the hero): any clip in it renders as a loop.
215
+ * (the hero): any clip in it renders as a loop. `format: 'png'` serves its images as
216
+ * PNG instead of WebP (M9 Decision 8: WebP smears flat artwork).
204
217
  */
205
218
  export interface MediaField extends FieldBase {
206
219
  kind: 'media';
207
220
  only?: MediaOnly;
208
221
  play?: 'loop';
222
+ format?: ImageFormat;
209
223
  }
210
224
 
225
+ /** The format a resized image is served in (M9 Decision 8). WebP unless a media field asks for PNG. */
226
+ export type ImageFormat = 'webp' | 'png';
227
+
211
228
  export type Field =
212
229
  | TextField
213
230
  | LockupField
@@ -288,9 +305,10 @@ export type BlockData<F extends Fields> = { [K in keyof F]: FieldValue<F[K]> };
288
305
 
289
306
  // ── Helpers ───────────────────────────────────────────────────────────────────
290
307
 
291
- export function text(options: { label?: string; help?: string; multiline?: boolean; heading?: boolean; max?: number } = {}): TextField {
292
- const field: TextField = { kind: 'text', label: options.label, multiline: options.multiline ?? false, heading: options.heading ?? false };
308
+ export function text(options: { label?: string; help?: string; multiline?: boolean; heading?: boolean; max?: number; lines?: boolean } = {}): TextField {
309
+ const field: TextField = { kind: 'text', label: options.label, multiline: options.multiline ?? options.lines ?? false, heading: options.heading ?? false };
293
310
  if (options.max !== undefined) field.max = options.max;
311
+ if (options.lines) field.lines = true;
294
312
  return withHelp(field, options);
295
313
  }
296
314
 
@@ -316,9 +334,14 @@ export function select<const V extends string>(options: {
316
334
  return withHelp({ kind: 'select', label: options.label, options: list, default: fallback }, options);
317
335
  }
318
336
 
319
- /** Rich text (Decision 3). `headings`: `['h2']` or `['h2', 'h3']`, off by default. `links`: on by default. */
320
- export function richText(options: { label?: string; help?: string; headings?: readonly HeadingKind[]; links?: boolean } = {}): RichTextField {
321
- return withHelp({ kind: 'richText', label: options.label, headings: [...(options.headings ?? [])], links: options.links ?? true }, options);
337
+ /**
338
+ * Rich text (Decision 3). `headings`: `['h2']` or `['h2', 'h3']`, off by default. `links`: on by default.
339
+ * `heading: true` (M9 Decision 11): the block's heading, bold and line breaks only (links off).
340
+ */
341
+ export function richText(options: { label?: string; help?: string; headings?: readonly HeadingKind[]; links?: boolean; heading?: boolean } = {}): RichTextField {
342
+ const field: RichTextField = { kind: 'richText', label: options.label, headings: [...(options.headings ?? [])], links: options.links ?? !options.heading };
343
+ if (options.heading) field.heading = true;
344
+ return withHelp(field, options);
322
345
  }
323
346
 
324
347
  /**
@@ -399,12 +422,14 @@ export function group<const F extends Fields>(fields: F, options: { label?: stri
399
422
 
400
423
  /**
401
424
  * An image or a clip (M5b Decision 3). `only: 'image'` or `'video'` takes one kind;
402
- * `play: 'loop'` makes the slot loop-only (a clip in it always plays as a loop).
425
+ * `play: 'loop'` makes the slot loop-only (a clip in it always plays as a loop);
426
+ * `format: 'png'` serves its images as PNG (M9 Decision 8).
403
427
  */
404
- export function media(options: { label?: string; help?: string; only?: MediaOnly; play?: 'loop' } = {}): MediaField {
428
+ export function media(options: { label?: string; help?: string; only?: MediaOnly; play?: 'loop'; format?: ImageFormat } = {}): MediaField {
405
429
  const field: MediaField = { kind: 'media', label: options.label };
406
430
  if (options.only !== undefined) field.only = options.only;
407
431
  if (options.play !== undefined) field.play = options.play;
432
+ if (options.format !== undefined) field.format = options.format;
408
433
  return withHelp(field, options);
409
434
  }
410
435
 
@@ -444,7 +469,7 @@ export function parseDay(value: unknown): string | null {
444
469
  return at.getUTCFullYear() === y && at.getUTCMonth() === mo - 1 && at.getUTCDate() === d ? m[0]! : null;
445
470
  }
446
471
 
447
- const richTextOptions = (field: RichTextField) => ({ headings: field.headings, links: field.links });
472
+ export const richTextOptions = (field: RichTextField): RichTextOptions => ({ headings: field.headings, links: field.links, ...(field.heading ? { heading: true } : {}) });
448
473
 
449
474
  function parseButton(field: ButtonsField, raw: Record<string, unknown>): ButtonValue {
450
475
  const style = typeof raw.style === 'string' && field.styles.some((o) => o.value === raw.style) ? raw.style : field.styles[0]!.value;
@@ -732,12 +757,18 @@ export type FieldWarningCode =
732
757
  | 'disallowed-kind'
733
758
  /** Rich text: a link where the field has links off, shown as its text. */
734
759
  | 'link-off'
760
+ /** Rich text: italic in a heading rich-text field (M9 Decision 11), shown as plain text. */
761
+ | 'italic-off'
735
762
  /** A value (or a rich-text entry) the field can't read: shown as empty, kept until the field is edited. */
736
763
  | 'unreadable'
737
764
  /** A link that fails the link rule (Decision 4): shown as text. */
738
765
  | 'link-rule'
739
766
  /** An internal link to a path no document has (Decision 4). */
740
767
  | 'link-missing'
768
+ /** M9 Decision 7: an internal link to a page hidden from the site. */
769
+ | 'link-hidden'
770
+ /** M9 Decision 7: a reference to a page hidden from the site (a warning, as `link-hidden` is). */
771
+ | 'reference-hidden'
741
772
  /** A link or button with only one of its label and its link: shows nothing. */
742
773
  | 'half-link'
743
774
  /** More buttons than the field's `max`: the extra ones don't show. */
@@ -782,6 +813,13 @@ export interface WarningContext {
782
813
  * address under it, since which ones it builds isn't known from its file name.
783
814
  */
784
815
  codeRoutes?: readonly CodeRoute[];
816
+ /** M9 Decision 7: hidden pages, their address (normalised) to their name. A link to one warns. */
817
+ hiddenPages?: ReadonlyMap<string, string>;
818
+ /**
819
+ * M9 Decision 7: hidden documents, `collection/path` to their name and address (links.ts
820
+ * `hiddenDocuments`). A reference to one warns.
821
+ */
822
+ hiddenDocuments?: ReadonlyMap<string, { label: string; url: string | null }>;
785
823
  }
786
824
 
787
825
  const join = (base: string, part: string | number) => (base === '' ? String(part) : `${base}.${part}`);
@@ -804,6 +842,11 @@ function hrefWarnings(href: string, path: string, context: WarningContext, out:
804
842
  if (context.addresses) {
805
843
  const target = internalLinkPath(href);
806
844
  if (target !== null && codeRouteAt(target, context.codeRoutes)) return;
845
+ const hidden = context.hiddenPages && hiddenLinkTarget(href, context.hiddenPages);
846
+ if (hidden) {
847
+ out.push({ path, code: 'link-hidden', message: hidden });
848
+ return;
849
+ }
807
850
  const missing = missingLinkTarget(href, context.addresses);
808
851
  if (missing) out.push({ path, code: 'link-missing', message: missing });
809
852
  }
@@ -834,6 +877,8 @@ export function fieldWarnings(field: Field, raw: unknown, context: WarningContex
834
877
  code: 'disallowed-kind',
835
878
  message: `${issue.kind === 'h1' ? 'An h1' : `A${/^[aeiouh]/i.test(issue.kind) ? 'n' : ''} ${issue.kind}`} isn't allowed in this field, so it shows as a paragraph.`,
836
879
  });
880
+ } else if (issue.code === 'italic-off') {
881
+ out.push({ path: at, code: 'italic-off', message: 'This heading takes bold only, so italic here shows as plain text.' });
837
882
  } else if (issue.code === 'link-off') {
838
883
  out.push({
839
884
  path: at,
@@ -898,6 +943,9 @@ export function fieldWarnings(field: Field, raw: unknown, context: WarningContex
898
943
  const v = parseField(field, raw) as string | null;
899
944
  if (v !== null && context.documentExists && !context.documentExists(field.to, v)) {
900
945
  out.push({ path, code: 'missing-reference', message: `This points at "${v}", which isn't in ${titleCase(field.to)} any more.` });
946
+ } else if (v !== null && context.hiddenDocuments) {
947
+ const message = hiddenReferenceTarget(field.to, v, context.hiddenDocuments);
948
+ if (message) out.push({ path, code: 'reference-hidden', message });
901
949
  }
902
950
  break;
903
951
  }
@@ -987,12 +1035,12 @@ export const isKeyPath = (path: string) => path !== '' && !splitPath(path).some(
987
1035
 
988
1036
  /**
989
1037
  * The fields that can hold the page's heading (M3 Decision 16: a lockup, or a text
990
- * field marked `heading`), as paths, in the order they're tried: the top-level ones in
1038
+ * or rich-text field marked `heading`, M9 Decision 11), as paths, in the order they're tried: the top-level ones in
991
1039
  * schema order, then those inside groups (`left.heading`), groups in schema order. A
992
1040
  * top-level heading always comes before a group's.
993
1041
  */
994
1042
  export function headingPaths(fields: Fields): string[] {
995
- const isHeading = (field: Field) => (field.kind === 'lockup' || field.kind === 'text') && !!field.heading;
1043
+ const isHeading = (field: Field) => (field.kind === 'lockup' || field.kind === 'text' || field.kind === 'richText') && !!field.heading;
996
1044
  const top = Object.entries(fields).filter(([, f]) => isHeading(f)).map(([key]) => key);
997
1045
  const grouped = Object.entries(fields).flatMap(([key, f]) => (f.kind === 'group' ? Object.entries(f.fields).filter(([, g]) => isHeading(g)).map(([sub]) => `${key}.${sub}`) : []));
998
1046
  return [...top, ...grouped];