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,257 @@
1
+ /**
2
+ * Live mode's connection to the server (M4 Decisions 9, 11 and 13): the status poll
3
+ * and the calls the bar and the pane make (Publish, Try again, history, a notice's
4
+ * Dismiss, Edit here, Sign out). Local mode never makes one (M3's editor).
5
+ *
6
+ * - **The poll**: `GET /_caracms/status?file=<the open document>` every `POLL_MS`
7
+ * while the tab is visible, and at once when it becomes visible again. It carries
8
+ * the tab's id, so it's also the lock's heartbeat and the tab's presence.
9
+ * - **Last-known data**: a poll that can't reach the server (a deploy replacing it,
10
+ * which Publish causes) keeps the last status and says `reachable: false`. Nothing
11
+ * is lost and nothing shows as an error; the next answer brings it back.
12
+ * - **Signed out**: any 401 (`SignedOutError`) sets `signedOut`; the next poll that
13
+ * answers 200 clears it (signing in from another tab brings the session back).
14
+ */
15
+ import { ENDPOINTS, TAB_HEADER, type HistoryResponse, type LiveProps, type LockRefusal, type LockResponse, type PublishResponse, type SignedOutError, type StatusResponse, type TryAgainResponse } from '../../types.ts';
16
+ import { createStore, type Store } from './store.ts';
17
+
18
+ /**
19
+ * Decision 13: every 5 s while the tab is visible. (The spec's Open questions may
20
+ * shorten it to 3 s while someone else is on the same document; not done unless
21
+ * Rich says so.)
22
+ */
23
+ export const POLL_MS = 5000;
24
+
25
+ /** Where sign out goes (Phase 3's route): `{ ok, location }` to a JSON request. */
26
+ export const SIGN_OUT = '/admin/login/signout';
27
+
28
+ export interface LiveState {
29
+ /** The last status the server gave (the page's own, from the server, until the first poll). */
30
+ status: StatusResponse | null;
31
+ /** The last poll got an answer. False while the server can't be reached (a deploy, say). */
32
+ reachable: boolean;
33
+ /** The session ended (a 401), until a poll answers 200 again. */
34
+ signedOut: SignedOutError | null;
35
+ /** This tab's own poll has answered: the page's first status had no tab, so its locks of yours can't say which tab. */
36
+ polled: boolean;
37
+ /** Which poll this answer came from (they're numbered as they start). */
38
+ seq: number;
39
+ }
40
+
41
+ export interface LiveSession {
42
+ readonly tab: string;
43
+ readonly state: Store<LiveState>;
44
+ /** How many polls have started: a lock answer newer than this outranks the poll in flight. */
45
+ started(): number;
46
+ poll(): Promise<void>;
47
+ /** Browser only: the poll loop and the visibility listener. Returns a stop. */
48
+ start(): () => void;
49
+ /** The server's answer, or why it didn't publish (a non-200, or the server out of reach): a failure is never shown as published. */
50
+ publish(): Promise<{ ok: true; body: PublishResponse } | { ok: false; error: string }>;
51
+ tryAgain(): Promise<TryAgainResponse>;
52
+ history(): Promise<HistoryResponse | null>;
53
+ dismiss(id: string): Promise<void>;
54
+ /** Edit here: take the open document's lock (moves your own at once). */
55
+ take(file: string): Promise<{ ok: true; lock: LockResponse } | { ok: false; refusal: LockRefusal | null }>;
56
+ /** Take over: move anyone's lock on the document to this tab (their autosave is committed first). */
57
+ takeOver(file: string): Promise<{ ok: true; lock: LockResponse } | { ok: false }>;
58
+ /** Someone typed, clicked or scrolled in this tab: each poll says how long ago (the lock's idle rule). */
59
+ active(): void;
60
+ signOut(): Promise<string>;
61
+ }
62
+
63
+ export interface LiveOptions {
64
+ fetch: typeof fetch;
65
+ props: LiveProps;
66
+ tab: string;
67
+ /** The open document's file, named by every poll. */
68
+ openFile: () => string | null;
69
+ now?: () => number;
70
+ setTimer?: (fn: () => void, ms: number) => unknown;
71
+ clearTimer?: (timer: unknown) => void;
72
+ log?: (message: string) => void;
73
+ }
74
+
75
+ /** The tab's id: kept for the tab's life in sessionStorage, so a reload is the same tab (its lock stays its own). */
76
+ export function tabId(): string {
77
+ const make = () => (typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `t${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`);
78
+ try {
79
+ const s = window.sessionStorage;
80
+ let id = s.getItem('caracms:tab');
81
+ if (!id) s.setItem('caracms:tab', (id = make()));
82
+ return id;
83
+ } catch {
84
+ return make();
85
+ }
86
+ }
87
+
88
+ /** What counts as someone using the tab, in the editor and in the previews (the lock's idle rule). */
89
+ export const ACTIVITY_EVENTS = ['keydown', 'pointerdown', 'wheel', 'input'] as const;
90
+
91
+ export function createLiveSession(options: LiveOptions): LiveSession {
92
+ const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
93
+ const clearTimer = options.clearTimer ?? ((t) => clearTimeout(t as ReturnType<typeof setTimeout>));
94
+ const log = options.log ?? ((message) => console.warn(`[caracms] ${message}`));
95
+ const state = createStore<LiveState>({ status: options.props.status, reachable: true, signedOut: null, polled: false, seq: 0 });
96
+ let seq = 0;
97
+ const now = options.now ?? (() => Date.now());
98
+ let lastInput = now();
99
+ let inFlight: Promise<void> | null = null;
100
+ let again = false;
101
+
102
+ const headers = (json: boolean): Record<string, string> => ({ Accept: 'application/json', [TAB_HEADER]: options.tab, ...(json ? { 'Content-Type': 'application/json' } : {}) });
103
+
104
+ /** A call to an editor route: the answer's body, or null when the server couldn't be reached. A 401 marks the session ended. */
105
+ async function call<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<{ status: number; body: T } | null> {
106
+ try {
107
+ const response = await options.fetch(path, {
108
+ method: init.method ?? 'GET',
109
+ headers: headers(init.body !== undefined),
110
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
111
+ });
112
+ const body = (await response.json().catch(() => ({}))) as T;
113
+ if (response.status === 401) {
114
+ const b = body as unknown as Partial<SignedOutError>;
115
+ state.update({ signedOut: { error: b.error ?? 'signed-out', code: 'signed-out', reason: b.reason === 'removed' ? 'removed' : 'signed-out', signIn: b.signIn ?? '/admin/login' }, reachable: true });
116
+ }
117
+ return { status: response.status, body };
118
+ } catch (error) {
119
+ log(`${path}: ${error instanceof Error ? error.message : String(error)}`);
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async function pollOnce(): Promise<void> {
125
+ const mine = ++seq;
126
+ const file = options.openFile();
127
+ // `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))) });
129
+ const answer = await call<StatusResponse>(`${ENDPOINTS.status}?${query}`);
130
+ if (!answer) {
131
+ state.update({ reachable: false });
132
+ return;
133
+ }
134
+ if (answer.status === 200 && answer.body && (answer.body as StatusResponse).mode === 'live') {
135
+ state.set((s) => ({ ...s, status: answer.body, reachable: true, signedOut: null, polled: true, seq: mine }));
136
+ } else if (answer.status === 401) {
137
+ // call() marked it.
138
+ } else if (answer.status >= 500 || answer.status === 404) {
139
+ // 503 unavailable (Redis), a deploy's gap, or the route not there yet: the server isn't answering for now.
140
+ state.update({ reachable: false });
141
+ }
142
+ }
143
+
144
+ async function poll(): Promise<void> {
145
+ if (inFlight) {
146
+ again = true;
147
+ return inFlight;
148
+ }
149
+ inFlight = (async () => {
150
+ try {
151
+ do {
152
+ again = false;
153
+ await pollOnce();
154
+ } while (again);
155
+ } finally {
156
+ inFlight = null;
157
+ }
158
+ })();
159
+ return inFlight;
160
+ }
161
+
162
+ const session: LiveSession = {
163
+ tab: options.tab,
164
+ state,
165
+ started: () => seq,
166
+ poll,
167
+ start() {
168
+ let timer: unknown = null;
169
+ let stopped = false;
170
+ /** One loop at a time: a restart starts a new generation, and the old one stops at its next turn. */
171
+ let generation = 0;
172
+ const visible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
173
+ const loop = (g: number) => {
174
+ if (stopped || g !== generation) return;
175
+ timer = setTimer(async () => {
176
+ timer = null;
177
+ if (g !== generation) return;
178
+ if (visible()) await poll();
179
+ loop(g);
180
+ }, options.props.pollMs ?? POLL_MS);
181
+ };
182
+ const restart = () => {
183
+ const g = ++generation;
184
+ if (timer !== null) clearTimer(timer);
185
+ timer = null;
186
+ void poll().finally(() => loop(g));
187
+ };
188
+ // Visible again: at once (Decision 13). The lock is re-taken by that poll if nobody else has it (Decision 9).
189
+ const onVisible = () => {
190
+ if (!visible()) return;
191
+ lastInput = now(); // coming back to the tab counts as using it
192
+ restart();
193
+ };
194
+ const onActivity = () => {
195
+ lastInput = now();
196
+ };
197
+ document.addEventListener('visibilitychange', onVisible);
198
+ for (const type of ACTIVITY_EVENTS) document.addEventListener(type, onActivity, { capture: true, passive: true });
199
+ restart();
200
+ return () => {
201
+ stopped = true;
202
+ if (timer !== null) clearTimer(timer);
203
+ document.removeEventListener('visibilitychange', onVisible);
204
+ for (const type of ACTIVITY_EVENTS) document.removeEventListener(type, onActivity, { capture: true });
205
+ };
206
+ },
207
+ async publish() {
208
+ const answer = await call<PublishResponse>(ENDPOINTS.publish, { method: 'POST', body: {} });
209
+ void poll();
210
+ if (!answer) return { ok: false, error: "The server couldn't be reached." };
211
+ if (answer.status !== 200 || !answer.body?.publish) {
212
+ const said = (answer.body as unknown as { error?: unknown } | null)?.error;
213
+ return { ok: false, error: typeof said === 'string' && said ? said : `The server answered ${answer.status}.` };
214
+ }
215
+ state.set((s) => (s.status ? { ...s, status: { ...s.status, publish: answer.body.publish } } : s));
216
+ return { ok: true, body: answer.body };
217
+ },
218
+ async tryAgain() {
219
+ const answer = await call<TryAgainResponse>(ENDPOINTS.tryAgain, { method: 'POST', body: {} });
220
+ void poll();
221
+ if (!answer) return { ok: false, error: "Couldn't reach the server." };
222
+ return answer.body;
223
+ },
224
+ async history() {
225
+ const answer = await call<HistoryResponse>(ENDPOINTS.history);
226
+ return answer && answer.status === 200 ? answer.body : null;
227
+ },
228
+ async dismiss(id) {
229
+ // Gone at once; the server agrees on the next poll.
230
+ state.set((s) => (s.status ? { ...s, status: { ...s.status, notices: s.status.notices.filter((n) => n.id !== id) } } : s));
231
+ await call(ENDPOINTS.notice, { method: 'POST', body: { id } });
232
+ },
233
+ active() {
234
+ lastInput = now();
235
+ },
236
+ async takeOver(file) {
237
+ const answer = await call<LockResponse>(ENDPOINTS.lock, { method: 'POST', body: { file, action: 'take-over' } });
238
+ void poll();
239
+ return answer?.status === 200 ? { ok: true, lock: answer.body } : { ok: false };
240
+ },
241
+ async take(file) {
242
+ const answer = await call<LockResponse | LockRefusal>(ENDPOINTS.lock, { method: 'POST', body: { file, action: 'take' } });
243
+ if (answer?.status === 200) return { ok: true, lock: answer.body as LockResponse };
244
+ return { ok: false, refusal: answer?.status === 409 ? (answer.body as LockRefusal) : null };
245
+ },
246
+ async signOut() {
247
+ try {
248
+ const response = await options.fetch(SIGN_OUT, { method: 'POST', headers: { Accept: 'application/json' } });
249
+ const body = (await response.json().catch(() => ({}))) as { location?: string };
250
+ return body.location ?? '/admin/login';
251
+ } catch {
252
+ return '/admin/login';
253
+ }
254
+ },
255
+ };
256
+ return session;
257
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The fields a block of the open document has (M5a Phase 5): a block's schema in a
3
+ * section builder or a shared block, or, for the one block a fields document or Site
4
+ * Settings is held as (document-store.ts), its collection's fields. Pure, so the
5
+ * overlays, the page's typing and the driver read the same thing.
6
+ */
7
+ import { EDITORS_FIELD, own } from '../../../document.ts';
8
+ import type { Fields } from '../../../fields.ts';
9
+ import type { EditorDocument, EditorSite } from '../../types.ts';
10
+
11
+
12
+ /** The fields of a fields document's collection, or of Site Settings (the site's own `settings.fields`). */
13
+ export function formFields(site: Pick<EditorSite, 'collections'>, collection: string): Fields {
14
+ return site.collections.find((c) => c.name === collection)?.fields ?? {};
15
+ }
16
+
17
+ /** The block types `collection`'s pages offer (config.ts `blocks`): null for every block. */
18
+ export function offeredBlocks(site: Pick<EditorSite, 'collections'>, collection: string): readonly string[] | null {
19
+ return site.collections.find((c) => c.name === collection)?.blocks ?? null;
20
+ }
21
+
22
+ /** Whether `collection`'s pages offer blocks of `type` (always, when it names none). */
23
+ export function offersBlock(site: Pick<EditorSite, 'collections'>, collection: string, type: string): boolean {
24
+ const offered = offeredBlocks(site, collection);
25
+ return !offered || offered.includes(type);
26
+ }
27
+
28
+ /**
29
+ * The fields the block `blockType` has in a document of `kind` from `collection`: a
30
+ * form's (fields, settings) are its collection's; a block's are its type's schema.
31
+ */
32
+ export function fieldsOfBlock(
33
+ site: Pick<EditorSite, 'blocks' | 'collections'>,
34
+ document: Pick<EditorDocument, 'kind'>,
35
+ collection: string,
36
+ block: { type: string; known: boolean },
37
+ ): Fields {
38
+ if (document.kind === 'fields') return formFields(site, collection);
39
+ if (document.kind === 'settings') return { editors: EDITORS_FIELD, ...formFields(site, collection) };
40
+ if (!block.known) return {};
41
+ return own(site.blocks, block.type)?.fields ?? {};
42
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A tiny observable value: the editor's state containers are built on it, and
3
+ * React reads them with `useStore` (context.tsx → useSyncExternalStore). Values are
4
+ * replaced, never mutated, so a snapshot is stable until the next `set`.
5
+ */
6
+ export interface Store<T> {
7
+ get(): T;
8
+ set(next: T | ((previous: T) => T)): void;
9
+ /** Merge into an object value. */
10
+ update(patch: Partial<T>): void;
11
+ subscribe(listener: (value: T, previous: T) => void): () => void;
12
+ }
13
+
14
+ export function createStore<T>(initial: T): Store<T> {
15
+ let value = initial;
16
+ const listeners = new Set<(value: T, previous: T) => void>();
17
+ const store: Store<T> = {
18
+ get: () => value,
19
+ set(next) {
20
+ const previous = value;
21
+ value = typeof next === 'function' ? (next as (p: T) => T)(previous) : next;
22
+ if (Object.is(value, previous)) return;
23
+ for (const listener of [...listeners]) listener(value, previous);
24
+ },
25
+ update(patch) {
26
+ store.set((previous) => {
27
+ for (const key of Object.keys(patch) as (keyof T)[]) {
28
+ if (!Object.is(previous[key], patch[key])) return { ...previous, ...patch };
29
+ }
30
+ return previous;
31
+ });
32
+ },
33
+ subscribe(listener) {
34
+ listeners.add(listener);
35
+ return () => listeners.delete(listener);
36
+ },
37
+ };
38
+ return store;
39
+ }
@@ -0,0 +1,93 @@
1
+ Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) GeistMono-Italic[wght].ttf: Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The unconfigured server's answer (M4 Decision 1): a production server missing a
3
+ * required variable still serves the public site, while `/admin` and `/_caracms/*`
4
+ * answer 503 with a plain sentence naming what's missing (logged once, server/mode.ts).
5
+ *
6
+ * Registered first (`addMiddleware({ order: 'pre' })` in ../index.ts), so it runs
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,
9
+ * `/_caracms/form` (M6 Decision 5): the form is part of the public site, which works
10
+ * without the editor.
11
+ *
12
+ * It also marks the preview route's pages (M8 review): a 200 HTML answer to the preview's GET
13
+ * gets `data-caracms-preview` on its `<html>` (types.ts → PREVIEW_MARK), the one sign the
14
+ * driver has that a frame loaded a preview and not an error page. The site's page component
15
+ * writes the `<html>`, so the route itself can't. Everything else, a POST's render included
16
+ * (only its body is morphed), passes untouched.
17
+ */
18
+ import type { MiddlewareHandler } from 'astro';
19
+ import { isFormEndpoint } from '../live/auth/guard.ts';
20
+ import { unconfiguredMessage } from '../live/mode.ts';
21
+ import { editorMode } from './server/mode.ts';
22
+ import { ENDPOINTS, PREVIEW_MARK } from './types.ts';
23
+
24
+ /** `html` with the preview's mark on its `<html>` (one is added after the doctype when the page has none). */
25
+ export function markPreviewHtml(html: string): string {
26
+ if (/<html\b/i.test(html)) return html.replace(/<html\b/i, `<html ${PREVIEW_MARK}`);
27
+ const doctype = /^\s*<!doctype[^>]*>/i.exec(html)?.[0] ?? '';
28
+ return `${doctype}<html ${PREVIEW_MARK}>${html.slice(doctype.length)}`;
29
+ }
30
+
31
+ async function markPreview(response: Response): Promise<Response> {
32
+ if (response.status !== 200 || !/^text\/html/i.test(response.headers.get('Content-Type') ?? '')) return response;
33
+ const headers = new Headers(response.headers);
34
+ headers.delete('Content-Length');
35
+ return new Response(markPreviewHtml(await response.text()), { status: response.status, statusText: response.statusText, headers });
36
+ }
37
+
38
+ export const isEditorPath = (pathname: string) =>
39
+ (pathname === '/admin' || pathname.startsWith('/admin/') || pathname.startsWith('/_caracms/')) && !isFormEndpoint(pathname);
40
+
41
+ export const onRequest: MiddlewareHandler = (context, next) => {
42
+ if (!isEditorPath(context.url.pathname)) return next();
43
+ const mode = editorMode();
44
+ if (mode.kind !== 'unconfigured') {
45
+ if (context.url.pathname !== ENDPOINTS.preview || context.request.method !== 'GET') return next();
46
+ return (async () => markPreview(await next()))();
47
+ }
48
+ const message = unconfiguredMessage(mode);
49
+ const api = context.url.pathname.startsWith('/_caracms/');
50
+ const escape = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
51
+ return Promise.resolve(
52
+ api
53
+ ? new Response(JSON.stringify({ error: message, code: 'unconfigured' }), {
54
+ status: 503,
55
+ headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
56
+ })
57
+ : new Response(`<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>Editor not set up</title><p>${escape(message)}</p>`, {
58
+ status: 503,
59
+ headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
60
+ }),
61
+ );
62
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Live mode's routes (M4 Phase 2) share this: each is one handler on the process's live
3
+ * editor (../../live/editor.ts), given the signed-in person from `locals`. In local
4
+ * mode they answer 404 `{ code: 'not-live' }`: M3's editor has no status, locks or
5
+ * Publish (Decision 1).
6
+ */
7
+ import type { APIRoute } from 'astro';
8
+ import { site } from 'virtual:caracms/config';
9
+ import type { LiveEditor } from '../../live/editor.ts';
10
+ import { personOf } from '../../live/locals.ts';
11
+ import { liveEditorOf } from '../server/mode.ts';
12
+
13
+ type Name = 'status' | 'lock' | 'publish' | 'history' | 'tryAgain' | 'notice';
14
+
15
+ export const liveRoute =
16
+ (name: Name): APIRoute =>
17
+ async ({ request, locals }) => {
18
+ const live: LiveEditor | null = await liveEditorOf(site);
19
+ if (!live) {
20
+ return new Response(JSON.stringify({ error: 'This route is for live mode; the editor is in local mode here.', code: 'not-live' }), {
21
+ status: 404,
22
+ headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
23
+ });
24
+ }
25
+ return live[name](request, personOf(locals));
26
+ };