caracms 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +65 -20
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +17 -3
- package/skills/caracms-adopt/look-same.mjs +24 -11
- package/skills/caracms-blocks/SKILL.md +69 -4
- package/skills/caracms-content/SKILL.md +2 -1
- package/skills/caracms-tina/SKILL.md +431 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cli/validate.ts +10 -5
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +20 -1
- package/src/components/Form.astro +76 -10
- package/src/components/Head.astro +4 -2
- package/src/components/Media.astro +27 -1
- package/src/components/RichText.astro +12 -4
- package/src/components/Text.astro +32 -9
- package/src/components/form-client.ts +7 -4
- package/src/components/sitemap.xml.ts +2 -1
- package/src/config.ts +32 -1
- package/src/content-schema.ts +7 -4
- package/src/content.ts +28 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +17 -12
- package/src/editor/client/fields/ReferenceField.tsx +6 -0
- package/src/editor/client/fields/RichTextField.tsx +38 -32
- package/src/editor/client/fields/host.tsx +5 -0
- package/src/editor/client/fields/parts.tsx +2 -1
- package/src/editor/client/fields/rich-text-doc.ts +28 -2
- package/src/editor/client/fields/rich-text-schema.ts +41 -2
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/nav/DocRow.tsx +6 -0
- package/src/editor/client/overlays/rich-text-bar.ts +6 -6
- package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/preview/driver.ts +5 -5
- package/src/editor/client/preview/field-text.ts +40 -2
- package/src/editor/client/preview/page-edit.ts +2 -2
- package/src/editor/client/preview/page-editor-registry.ts +4 -3
- package/src/editor/client/preview/page-editor.ts +3 -3
- package/src/editor/client/state/document-store.ts +20 -2
- package/src/editor/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/editor.css +2 -0
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +27 -6
- package/src/fields.ts +63 -15
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +17 -4
- package/src/index.ts +53 -7
- package/src/links.ts +35 -0
- package/src/live/README.md +5 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +54 -24
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +177 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +15 -4
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +40 -7
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- package/src/serialize.ts +10 -1
|
@@ -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
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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' } });
|
package/src/editor/middleware.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)}
|
package/src/editor/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
433
|
-
|
|
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
|
-
/**
|
|
442
|
-
|
|
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? })
|
|
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
|
-
/**
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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];
|