caracms 0.0.0-stage → 0.2.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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Live mode's editor server (M4 Phase 2): the live drafts store, the sweep, locks and
3
+ * presence, and the handlers behind `/_caracms/status`, `lock`, `publish`, `history`,
4
+ * `try-again` and `notice` (the routes in ../editor/routes/ are one line each).
5
+ *
6
+ * One per process, beside the runtime (`liveEditor`), or made by tests
7
+ * (`createLiveEditor`) on a runtime from `createLiveRuntime` with the in-memory
8
+ * backend, the Redis stand-in and a fake clock.
9
+ *
10
+ * Every handler takes the request and the signed-in person (Phase 3's guard puts it
11
+ * in `locals`; locals.ts). **No person is a 401 `{ error: 'signed-out' }`**: defence in
12
+ * depth, the guard is the real check. A request that isn't GET also has to come from
13
+ * this site (write.ts → checkOrigin). Redis out past its budget is a 503 `unavailable`.
14
+ *
15
+ * **A poll asks GitHub for nothing** beyond `git.check()` (once a minute) and the
16
+ * deployment reads while a publish is in flight (git.ts, every 10 s at most).
17
+ *
18
+ * State lives in Redis and git (two servers run during a deploy); what's held here
19
+ * is the sweep's "am I ticking" and git.ts's caches.
20
+ */
21
+ import { SITE_SETTINGS_FILE, type ResolvedSite } from '../config.ts';
22
+ import { brandFrom, editorBrand } from '../editor/brand.ts';
23
+ import type { DraftsStore } from '../editor/server/drafts-store.ts';
24
+ import { handleCreate, handleDelete } from '../editor/server/create.ts';
25
+ import { checkOrigin, confinePath, fileVersion, handleWrite, tabOf, WriteRefused } from '../editor/server/write.ts';
26
+ import { missingAltIn } from '../editor/server/media/alt.ts';
27
+ import type {
28
+ ApiError,
29
+ HistoryResponse,
30
+ LockRefusal,
31
+ LockResponse,
32
+ MissingAltView,
33
+ NoticeView,
34
+ PublishResponse,
35
+ PublishView,
36
+ StatusResponse,
37
+ TryAgainResponse,
38
+ WaitingView,
39
+ } from '../editor/types.ts';
40
+ import { Autosaves } from './autosaves.ts';
41
+ import { lastChangeOf, liveDraftsStore, unavailable } from './drafts-store.ts';
42
+ import { movesIn, titleOf } from './git.ts';
43
+ import { parseRedirects, type Redirect } from '../redirects.ts';
44
+ import { Locks } from './locks.ts';
45
+ import { displayName, type Person } from './messages.ts';
46
+ import type { LiveSettings } from './mode.ts';
47
+ import { RedisUnavailableError } from './redis.ts';
48
+ import { liveRuntime, type LiveRuntime } from './runtime.ts';
49
+ import { Sweep, type Schedule } from './sweep.ts';
50
+
51
+ type Handler = (request: Request, person: Person | null) => Promise<Response>;
52
+
53
+ export interface LiveEditor {
54
+ readonly runtime: Pick<LiveRuntime, 'site' | 'settings' | 'git' | 'redis' | 'timings' | 'now'>;
55
+ readonly autosaves: Autosaves;
56
+ readonly locks: Locks;
57
+ readonly sweep: Sweep;
58
+ readonly store: DraftsStore;
59
+ write: Handler;
60
+ /** M5a Phase 3: POST /_caracms/create and /_caracms/delete over the live store (the routes call the store the same way). */
61
+ create: Handler;
62
+ delete: Handler;
63
+ status: Handler;
64
+ lock: Handler;
65
+ publish: Handler;
66
+ history: Handler;
67
+ tryAgain: Handler;
68
+ notice: Handler;
69
+ }
70
+
71
+ export interface LiveEditorOptions {
72
+ /** The sweep's timer (tests drive it by hand). */
73
+ schedule?: Schedule;
74
+ sleep?: (ms: number) => Promise<void>;
75
+ log?: (line: string) => void;
76
+ prefix?: string;
77
+ }
78
+
79
+ const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
80
+ const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: HEADERS });
81
+ const sameEmail = (a: Person, b: Person) => a.email.toLowerCase() === b.email.toLowerCase();
82
+
83
+ /** A notice id: `kept:<file>:<commit>` or `stood:<commit>:<file>:<email>`, no spaces or controls, capped. */
84
+ const NOTICE_ID = /^(kept|stood):[^\s\p{C}]+$/u;
85
+ const NOTICE_ID_MAX = 512;
86
+
87
+ interface StoodNotice {
88
+ id: string;
89
+ for: string;
90
+ file: string;
91
+ title: string;
92
+ by: Person;
93
+ at: string;
94
+ }
95
+
96
+ /** Map a site file to its document (collection and path), or nulls. */
97
+ /** Site Settings' redirects, from its text (none when it's missing or unreadable). */
98
+ function redirectsIn(text: string | null): Redirect[] {
99
+ try {
100
+ return parseRedirects((JSON.parse(text ?? '{}') as { redirects?: unknown }).redirects);
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ function docOf(site: Pick<ResolvedSite, 'root' | 'collections'>, file: string): { collection: string | null; path: string | null } {
107
+ try {
108
+ const c = confinePath(site as ResolvedSite, file);
109
+ return { collection: c.collection, path: c.path };
110
+ } catch {
111
+ return { collection: null, path: null };
112
+ }
113
+ }
114
+
115
+ export async function createLiveEditor(
116
+ runtime: Pick<LiveRuntime, 'site' | 'settings' | 'git' | 'redis' | 'timings' | 'now'>,
117
+ options: LiveEditorOptions = {},
118
+ ): Promise<LiveEditor> {
119
+ const { site, settings, git, redis, timings, now } = runtime;
120
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
121
+ const prefix = options.prefix ?? 'caracms:';
122
+ const log = options.log ?? ((line: string) => console.log(`[caracms] ${line}`));
123
+ const autosaves = new Autosaves(redis, timings, now, prefix);
124
+ const locks = new Locks(redis, timings, now, async (file) => (await autosaves.get(file)) !== null, prefix);
125
+ const sweep = new Sweep({ redis, git, autosaves, locks, timings, now, schedule: options.schedule, sleep: options.sleep, log, prefix });
126
+ const store = liveDraftsStore({ site, git, autosaves, locks, sweep, now });
127
+ // Publish's step 1 and Decision 8's "commit every pending autosave first".
128
+ git.commitPending = async () => {
129
+ await sweep.commitAll();
130
+ };
131
+ // Decision 11: a file main changed that no editor has a record for reads "only merges"
132
+ // (`by: null`, known), not unknown. An editor's record is never overwritten.
133
+ git.tookIn = async (files) => {
134
+ const at = new Date(now()).toISOString();
135
+ for (const { file, text } of files) await autosaves.setChangedIfAbsent(file, { by: null, version: fileVersion(text), at });
136
+ };
137
+ await sweep.start().catch((error: unknown) => log(`sweep: the startup check failed (${error instanceof Error ? error.message : String(error)}); the next autosave starts it`));
138
+ // Decision 8's startup check, here and not in the runtime's start: only now is the
139
+ // hook above in place, so a merge from main commits every pending autosave first.
140
+ await git.check({ force: true }).catch((error: unknown) => log(`check at startup: ${error instanceof Error ? error.message : String(error)}`));
141
+
142
+ const keys = {
143
+ publishLock: `${prefix}publish:lock`,
144
+ dismissed: `${prefix}dismissed`,
145
+ notices: `${prefix}notices`,
146
+ };
147
+
148
+ /** Common gate: a person, the same origin for anything but GET, Redis outages as 503. */
149
+ const handler =
150
+ (methods: string[], fn: (request: Request, person: Person) => Promise<Response>): Handler =>
151
+ async (request, person) => {
152
+ try {
153
+ if (!methods.includes(request.method)) {
154
+ return new Response(JSON.stringify({ error: `This route takes ${methods.join(' or ')}.` } satisfies ApiError), {
155
+ status: 405,
156
+ headers: { ...HEADERS, Allow: methods.join(', ') },
157
+ });
158
+ }
159
+ if (!person) return json(401, { error: 'signed-out', code: 'signed-out' } satisfies ApiError);
160
+ if (request.method !== 'GET') checkOrigin(request);
161
+ return await fn(request, person);
162
+ } catch (error) {
163
+ const refused = error instanceof RedisUnavailableError ? unavailable(error) : error;
164
+ if (refused instanceof WriteRefused) return json(refused.status, { error: refused.message, ...refused.body });
165
+ log(`${new URL(request.url).pathname} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
166
+ return json(502, { error: `That didn't work (${error instanceof Error ? error.message : String(error)}).` } satisfies ApiError);
167
+ }
168
+ };
169
+
170
+ const readBody = async (request: Request): Promise<Record<string, unknown>> => {
171
+ const raw = await request.text();
172
+ if (raw.length > 16_384) throw new WriteRefused(413, 'That body is too large.');
173
+ try {
174
+ const body = JSON.parse(raw || '{}') as unknown;
175
+ if (body && typeof body === 'object' && !Array.isArray(body)) return body as Record<string, unknown>;
176
+ } catch {
177
+ /* below */
178
+ }
179
+ throw new WriteRefused(400, 'Send a JSON object.');
180
+ };
181
+
182
+ /** Is there a document at this file: in the copy, or a new one's autosave, and not deleted (M5a)? */
183
+ const exists = async (file: string): Promise<boolean> => {
184
+ const save = (await autosaves.get(file))?.value;
185
+ return save ? !save.gone : git.read(file) !== null;
186
+ };
187
+
188
+ /** A document file that exists (the copy, or a new document's autosave), or a 400/404. */
189
+ const documentFile = async (file: unknown): Promise<string> => {
190
+ const target = confinePath(site, file);
191
+ if (!(await exists(file as string))) throw new WriteRefused(404, `${site.collections[target.collection]!.label}: no document "${target.path}".`);
192
+ return file as string;
193
+ };
194
+
195
+ // The alt list per waiting document, kept while its text stays the same (the poll asks every 5 s).
196
+ const altKept = new Map<string, { text: string; list: MissingAltView[] }>();
197
+ const missingAltOf = (file: string, collection: string | null, text: string | null): MissingAltView[] => {
198
+ if (text === null) return [];
199
+ const kept = altKept.get(file);
200
+ if (kept && kept.text === text) return kept.list;
201
+ const list = missingAltIn(site, collection, text);
202
+ if (altKept.size > 500) altKept.clear();
203
+ altKept.set(file, { text, list });
204
+ return list;
205
+ };
206
+
207
+ const publishView = async (): Promise<PublishView> => {
208
+ const running = (await redis.get(keys.publishLock)) !== null;
209
+ let st;
210
+ try {
211
+ st = await git.publishStatus();
212
+ } catch (error) {
213
+ log(`publish status: ${error instanceof Error ? error.message : String(error)}`);
214
+ const record = await git.lastPublish();
215
+ st = { state: record ? ('publishing' as const) : ('none' as const), record, assumed: false, canTryAgain: false };
216
+ }
217
+ return { state: st.state, running, record: st.record, assumed: st.assumed, canTryAgain: st.canTryAgain, hasToken: settings.railway !== null };
218
+ };
219
+
220
+ const notices = async (person: Person): Promise<NoticeView[]> => {
221
+ const dismissed = new Set(await redis.smembers(keys.dismissed));
222
+ const out: NoticeView[] = [];
223
+ for (const n of git.keptDraftNotices()) {
224
+ const id = `kept:${n.file}:${n.commit}`;
225
+ if (!dismissed.has(id)) out.push({ id, kind: 'draft-kept', file: n.file, title: git.titleFor(n.file) });
226
+ }
227
+ for (const raw of Object.values(await redis.hgetall(keys.notices))) {
228
+ try {
229
+ const n = JSON.parse(raw) as StoodNotice;
230
+ if (n.for === person.email.toLowerCase()) out.push({ id: n.id, kind: 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
231
+ } catch {
232
+ /* skip */
233
+ }
234
+ }
235
+ return out;
236
+ };
237
+
238
+ // The write route: M3's handler over the live store, which answers 401 with no person.
239
+ const write: Handler = (request, person) => handleWrite(request, store, { person });
240
+ // M5a Phase 3: creating and deleting, the same way (editor/server/create.ts over the live store).
241
+ const create: Handler = (request, person) => handleCreate(request, store, { person });
242
+ const remove: Handler = (request, person) => handleDelete(request, store, { person });
243
+
244
+ const status = handler(['GET'], async (request, person) => {
245
+ const url = new URL(request.url);
246
+ const tab = tabOf(request);
247
+ const asked = url.searchParams.get('file');
248
+ let file: string | null = null;
249
+ // Any document path: one deleted since the tab opened it is answered with who deleted it (M5a), not a 404.
250
+ if (asked) {
251
+ confinePath(site, asked);
252
+ file = asked;
253
+ }
254
+ // The heartbeat and presence ride on the poll (Decisions 9 and 13). `input` is how long
255
+ // ago someone typed, clicked or scrolled in the tab: an idle tab stops renewing its lock,
256
+ // so it lapses by itself (Take over, 2026-10-15). A tab that doesn't say is treated as active.
257
+ const input = Number(url.searchParams.get('input'));
258
+ const active = !url.searchParams.has('input') || !(input >= timings.lockIdleMs);
259
+ if (tab) {
260
+ await locks.present(tab, person, file);
261
+ if (file && active) await locks.beat(file, tab, person);
262
+ }
263
+ // Decision 8's check, throttled to once a minute and shared inside git.ts. Started, not
264
+ // awaited: the poll answers with what's known, and never waits on GitHub.
265
+ void git.check().catch((error: unknown) => log(`check: ${error instanceof Error ? error.message : String(error)}`));
266
+
267
+ const saves = (await autosaves.all()).map((r) => r.value);
268
+ if (saves.length) sweep.poke(); // they may have come in on the other server during a deploy
269
+ const pending = new Set(saves.map((a) => a.file));
270
+ const presence = await locks.presence();
271
+ const lockViews = [...(await locks.all())]
272
+ .filter(([f, r]) => locks.holds(r.lock, pending.has(f)))
273
+ .map(([f, r]) => locks.view(f, r.lock, tab, person));
274
+
275
+ // "Editing now" (the mockup's "Aoife is still in it"): someone other than this tab holds the lock.
276
+ const editingNow = (f: string): Person[] => {
277
+ const l = lockViews.find((x) => x.file === f);
278
+ return l && l.mine !== 'this-tab' ? [l.person] : [];
279
+ };
280
+ const waiting = new Map<string, WaitingView>();
281
+ for (const w of git.waiting()) waiting.set(w.file, { ...w, ...docOf(site, w.file), uncommitted: false, editingNow: editingNow(w.file), missingAlt: [] });
282
+ for (const a of saves) {
283
+ // M5a: new, deleted or changed, against main. Made and deleted again since the last Publish: not waiting.
284
+ const change = git.changeOf(a.file, a.text);
285
+ if (change === null) {
286
+ waiting.delete(a.file);
287
+ continue;
288
+ }
289
+ const at = new Date(a.updatedAt).toISOString();
290
+ const w: WaitingView = waiting.get(a.file) ?? {
291
+ file: a.file,
292
+ change,
293
+ ...docOf(site, a.file),
294
+ title: '',
295
+ editors: [],
296
+ changedAt: null,
297
+ keptDraft: false,
298
+ uncommitted: false,
299
+ editingNow: editingNow(a.file),
300
+ missingAlt: [],
301
+ };
302
+ w.change = change;
303
+ w.title = a.gone ? a.gone.title : titleOf(a.file, a.text);
304
+ w.uncommitted = true;
305
+ w.editors = [{ person: a.editor, at }, ...w.editors.filter((e) => !sameEmail(e.person, a.editor))];
306
+ if (!w.changedAt || at > w.changedAt) w.changedAt = at;
307
+ waiting.set(a.file, w);
308
+ }
309
+ // M5b Decision 9: each image with no alt text, from the document as it will publish (the
310
+ // autosave's text, else the copy's). A deleted document has none.
311
+ const textNow = new Map(saves.map((a) => [a.file, a.text]));
312
+ for (const w of waiting.values()) {
313
+ w.missingAlt = w.change === 'deleted' ? [] : missingAltOf(w.file, w.collection, textNow.has(w.file) ? (textNow.get(w.file) ?? null) : git.read(w.file));
314
+ }
315
+ // Pre-M7 item 8: a moved document is one entry, on its new file, with its old address.
316
+ // Read from the moves as recorded, not Site Settings' redirects: removing the redirect leaves it a move.
317
+ const readNow = (f: string) => (textNow.has(f) ? (textNow.get(f) ?? null) : git.read(f));
318
+ const moves = movesIn(site, [...waiting.keys()], readNow, await autosaves.moves());
319
+ const redirects = moves.from.size ? redirectsIn(readNow(SITE_SETTINGS_FILE)) : [];
320
+ for (const [file, from] of moves.from) {
321
+ const w = waiting.get(file)!;
322
+ w.movedFrom = from;
323
+ w.movedRedirect = redirects.some((r) => r.from === from);
324
+ }
325
+ for (const file of moves.to.keys()) waiting.delete(file);
326
+ const list = [...waiting.values()].sort((a, b) => (b.changedAt ?? '').localeCompare(a.changedAt ?? '') || a.file.localeCompare(b.file));
327
+
328
+ let open: StatusResponse['open'] = null;
329
+ if (file) {
330
+ const lock = lockViews.find((l) => l.file === file) ?? null;
331
+ // M5a Decision 12: a viewer learns the document was deleted, and by whom, on this poll.
332
+ let deleted: NonNullable<StatusResponse['open']>['deleted'] = null;
333
+ if (!(await exists(file))) {
334
+ const record = await autosaves.changed(file);
335
+ deleted = record?.gone ? { by: record.by, at: record.at } : { by: null, at: null };
336
+ }
337
+ open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file), deleted };
338
+ }
339
+ const body: StatusResponse = {
340
+ mode: 'live',
341
+ you: person,
342
+ tab,
343
+ now: new Date(now()).toISOString(),
344
+ timeZone: site.timeZone,
345
+ open,
346
+ waiting: list,
347
+ locks: lockViews,
348
+ presence,
349
+ notices: await notices(person),
350
+ publish: await publishView(),
351
+ // M5a Decision 10: from the committed copy, never an autosave.
352
+ brand: editorBrand(brandFrom(git.read(SITE_SETTINGS_FILE), site)),
353
+ };
354
+ return json(200, body);
355
+ });
356
+
357
+ const lock = handler(['POST'], async (request, person) => {
358
+ const tab = tabOf(request);
359
+ if (!tab) throw new WriteRefused(400, 'Lock requests name their tab (X-Caracms-Tab).', { code: 'no-tab' });
360
+ const body = await readBody(request);
361
+ // A release needs no document (a deleter's tab lets go of one it deleted, M5a); take and take over do.
362
+ const file = body.action === 'release' ? (confinePath(site, body.file), body.file as string) : await documentFile(body.file);
363
+ if (body.action === 'release') {
364
+ const r = await locks.release(file, tab, person);
365
+ const view = r && locks.holds(r.lock, (await autosaves.get(file)) !== null) ? locks.view(file, r.lock, tab, person) : null;
366
+ return json(200, { state: Locks.state(view), lock: view } satisfies LockResponse);
367
+ }
368
+ if (body.action === 'take-over') {
369
+ // Take over: the lock moves first, so the old holder's tab can't write again; then
370
+ // their autosave is committed under their name, and this tab opens from `drafts`.
371
+ const r = await locks.takeOver(file, tab, person);
372
+ if (r.from && !sameEmail(r.from.person, person)) log(`${person.email} took over ${file} from ${r.from.person.email}`);
373
+ // Another server mid-sweep (a deploy) makes a commit a no-op: try a few times, or
374
+ // this tab's first write would replace their autosave and their name with it.
375
+ for (let attempt = 0; attempt < 5 && (await autosaves.get(file)) !== null; attempt++) {
376
+ if ((await sweep.commitFiles([file])).ran) break;
377
+ await sleep(500);
378
+ }
379
+ return json(200, { state: 'yours', lock: locks.view(file, r.lock, tab, person) } satisfies LockResponse);
380
+ }
381
+ if (body.action !== 'take') throw new WriteRefused(400, 'action must be "take", "take-over" or "release".');
382
+ const r = await locks.take(file, tab, person);
383
+ const view = locks.view(file, r.lock, tab, person);
384
+ if (!r.ok) {
385
+ return json(409, {
386
+ error: `${displayName(view.person)} is editing this document.`,
387
+ code: 'locked',
388
+ lock: view,
389
+ state: 'theirs',
390
+ lastChange: await lastChangeOf({ git, autosaves }, file),
391
+ } satisfies LockRefusal);
392
+ }
393
+ return json(200, { state: 'yours', lock: view } satisfies LockResponse);
394
+ });
395
+
396
+ const publish = handler(['POST'], async (_request, person) => {
397
+ const before = await locks.presence();
398
+ const result = await git.publish(person);
399
+ if (result.kind === 'published') {
400
+ // "Aoife published Home as it stood at 14:20" for whoever had a published document open (Decision 10 step 1).
401
+ const { record } = result;
402
+ const fields: Record<string, string> = {};
403
+ for (const p of before) {
404
+ const i = record.files.indexOf(p.file);
405
+ if (i === -1) continue;
406
+ for (const someone of p.people) {
407
+ if (sameEmail(someone, person)) continue;
408
+ const id = `stood:${record.commit}:${p.file}:${someone.email.toLowerCase()}`;
409
+ const notice: StoodNotice = { id, for: someone.email.toLowerCase(), file: p.file, title: result.fileTitles[i] ?? titleOf(p.file, null), by: person, at: record.at };
410
+ fields[id] = JSON.stringify(notice);
411
+ }
412
+ }
413
+ if (Object.keys(fields).length) {
414
+ await redis.hset(keys.notices, fields);
415
+ await redis.pexpire(keys.notices, timings.autosaveTtlMs);
416
+ }
417
+ }
418
+ return json(200, { result: result.kind, publish: await publishView() } satisfies PublishResponse);
419
+ });
420
+
421
+ const history = handler(['GET'], async () => {
422
+ const entries = await git.history({ refresh: true });
423
+ return json(200, { timeZone: site.timeZone, entries } satisfies HistoryResponse);
424
+ });
425
+
426
+ const tryAgain = handler(['POST'], async () => {
427
+ if (!settings.railway) return json(409, { ok: false, error: 'No CARACMS_RAILWAY_TOKEN: a developer can redeploy from Railway.' } satisfies TryAgainResponse);
428
+ const st = await publishView();
429
+ if (st.state !== 'failed') return json(409, { ok: false, error: 'The last publish hasn\'t failed, so there\'s nothing to try again.' } satisfies TryAgainResponse);
430
+ const r = await git.tryAgain();
431
+ return r.ok ? json(200, { ok: true } satisfies TryAgainResponse) : json(502, { ok: false, error: r.reason } satisfies TryAgainResponse);
432
+ });
433
+
434
+ // Dismiss: only a notice this person is shown now (a draft-kept notice is shared, so
435
+ // anyone signed in sees it and may dismiss it; "published as it stood" is per person).
436
+ const notice = handler(['POST'], async (request, person) => {
437
+ const body = await readBody(request);
438
+ const id = body.id;
439
+ if (typeof id !== 'string' || id.length > NOTICE_ID_MAX || !NOTICE_ID.test(id)) throw new WriteRefused(400, '"id" must be a notice\'s id.');
440
+ const shown = (await notices(person)).find((n) => n.id === id);
441
+ if (!shown) throw new WriteRefused(404, 'There\'s no such notice for you to dismiss.');
442
+ if (shown.kind === 'draft-kept') {
443
+ await redis.sadd(keys.dismissed, id);
444
+ await redis.pexpire(keys.dismissed, timings.autosaveTtlMs);
445
+ } else await redis.hdel(keys.notices, id);
446
+ return json(200, { ok: true });
447
+ });
448
+
449
+ return { runtime, autosaves, locks, sweep, store, write, create, delete: remove, status, lock, publish, history, tryAgain, notice };
450
+ }
451
+
452
+ const SHARED = Symbol.for('caracms.liveEditor');
453
+ type Shared = { [SHARED]?: Promise<LiveEditor> };
454
+
455
+ /** The process's live editor, beside the shared runtime (runtime.ts → liveRuntime). */
456
+ export function liveEditor(site: ResolvedSite, settings: LiveSettings): Promise<LiveEditor> {
457
+ const g = globalThis as Shared;
458
+ g[SHARED] ??= liveRuntime(site, settings)
459
+ .then((rt) => createLiveEditor(rt))
460
+ .catch((error: unknown) => {
461
+ delete g[SHARED];
462
+ throw error;
463
+ });
464
+ return g[SHARED];
465
+ }