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,617 @@
1
+ /**
2
+ * The bar's waiting count and Publish, and their popovers (M4 Decisions 10, 12 and
3
+ * 18; the mockup's pubHTML, waitingPop and historyPop: #waiting #nothing #publishing
4
+ * #live #failed #blocked #history). Live mode only.
5
+ *
6
+ * - **The bar's button**: "3 waiting" with who, and Publish; "Publishing…" with the
7
+ * running bar; "Live · 14:12"; "Publish failed" with Try again; "Nothing waiting".
8
+ * Off while the server can't be reached or you're signed out.
9
+ * - **The popover is the summary**: each waiting document, who changed it and when,
10
+ * "Editing now", uncommitted changes included (the status merges them), the merge
11
+ * notice where the draft was kept, then the final Publish button. Then publishing →
12
+ * live, or failed with Try again, or failed with the line that a developer can
13
+ * redeploy from Railway when there's no token (`hasToken: false`).
14
+ * - **History**: who published, when, which documents, and whether each went live,
15
+ * in the site's time zone, named once. Read when the waiting popover opens (on
16
+ * intent), so it's there when History is pressed, and the last read shows while
17
+ * it's read again.
18
+ * - **Images without alt text** (M5b Decision 9; #publish-alt, #fix-alt-list,
19
+ * #fix-alt-locked): each waiting document with an image missing alt text is marked,
20
+ * naming each slot by its label (`WaitingView.missingAlt`, worked out by the server
21
+ * from the document as it will publish), with **Add alt text**, which opens the
22
+ * document on that field (a list row expanded, the alt input focused; a document
23
+ * someone else holds opens read-only with the field shown). The bar's count gets a
24
+ * warning dot. **Publish stays on and one click**: nothing here holds it back.
25
+ */
26
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
27
+ import { useEditor, useStore } from '../context.tsx';
28
+ import type { Editor } from '../editor.ts';
29
+ import { Icon } from '../shell/Icons.tsx';
30
+ import type { HistoryEntryView, MissingAltView, Person, StatusResponse, WaitingView } from '../../types.ts';
31
+ import { formKey, PAGE_SCOPE } from '../state/document-store.ts';
32
+ import { focusSidebarField } from '../preview/page-edit.ts';
33
+ import { clock, displayName, joinNames, samePerson, when, zoneLine } from './format.ts';
34
+ import { Avatar } from './marks.tsx';
35
+ import { docIcon } from '../nav/DocRow.tsx';
36
+ import { singular } from '../documents/actions.ts';
37
+
38
+ const histories = new WeakMap<Editor, { entries: HistoryEntryView[]; timeZone: string } | null>();
39
+
40
+ /** Read the history (at most once in flight), keeping the last read for the next open. */
41
+ function readHistory(editor: Editor, set?: (h: { entries: HistoryEntryView[]; timeZone: string }) => void) {
42
+ void editor.liveSession?.history().then((h) => {
43
+ if (!h) return;
44
+ histories.set(editor, h);
45
+ set?.(h);
46
+ });
47
+ }
48
+
49
+ /** The images without alt text across the waiting documents (a deleted one has none: the server leaves it out too). */
50
+ const altGaps = (waiting: readonly WaitingView[]): number => waiting.reduce((n, w) => n + (w.change === 'deleted' ? 0 : (w.missingAlt?.length ?? 0)), 0);
51
+
52
+ /**
53
+ * Add alt text (Decision 9): open the document on the image's field. A list item opens its
54
+ * row, expanded; the alt input is focused. A document someone else holds opens read-only
55
+ * (M4's lock), its field shown but not focused.
56
+ */
57
+ export async function openAtAlt(editor: Editor, item: Pick<WaitingView, 'collection' | 'path'>, gap: MissingAltView, options: { held?: boolean } = {}): Promise<void> {
58
+ if (item.collection === null || item.path === null) return;
59
+ editor.setPop(null);
60
+ await editor.openDocument({ collection: item.collection, path: item.path });
61
+ const session = editor.doc.get();
62
+ const slot = gap.path.replace(/\.alt$/, '');
63
+ if (gap.block === null && session.document.kind === 'sections') {
64
+ // M6c (G2): a page field's image, in the page's own group at its document level.
65
+ editor.view.update({ level: 'document', field: `${PAGE_SCOPE}.${slot}`, picked: false, pop: null });
66
+ } else {
67
+ const key = gap.block !== null && session.document.kind === 'sections' ? editor.doc.keyAt(gap.block) : formKey(session);
68
+ if (!key) return;
69
+ editor.openBlock(key, slot, { picked: false });
70
+ }
71
+ const held = () => !!options.held || editor.access.get().readOnly;
72
+ let tries = 30;
73
+ const attempt = () => {
74
+ const shown = '#side .side-level:not([hidden])';
75
+ const input = document.querySelector<HTMLInputElement>(`${shown} input[data-alt="${CSS.escape(slot)}"]`);
76
+ const wrap = document.querySelector<HTMLElement>(`${shown} [data-path="${CSS.escape(slot)}"]`);
77
+ if (wrap && held()) {
78
+ // Someone else holds it: read-only, the field shown but not focused.
79
+ wrap.scrollIntoView({ block: 'center' });
80
+ return;
81
+ }
82
+ if (input && !input.disabled) {
83
+ input.scrollIntoView({ block: 'center' });
84
+ input.focus({ preventScroll: true });
85
+ if (document.activeElement === input) return;
86
+ }
87
+ if (tries-- > 0) requestAnimationFrame(attempt);
88
+ else if (!held()) focusSidebarField(slot);
89
+ };
90
+ requestAnimationFrame(attempt);
91
+ }
92
+
93
+ const people = (waiting: readonly WaitingView[]): Person[] => {
94
+ const out: Person[] = [];
95
+ for (const w of waiting) for (const e of w.editors) if (!out.some((p) => samePerson(p, e.person))) out.push(e.person);
96
+ return out;
97
+ };
98
+
99
+ export function PublishButton() {
100
+ const editor = useEditor();
101
+ const status = useStore(editor.liveState, (s) => s.status);
102
+ const reachable = useStore(editor.liveState, (s) => s.reachable);
103
+ const signedOut = useStore(editor.liveState, (s) => s.signedOut);
104
+ const keep = useStore(editor.keep, (k) => k.state);
105
+ const pop = useStore(editor.view, (v) => v.pop);
106
+ if (!status) return <span data-slot="publish" hidden />;
107
+ const expanded = pop === 'waiting' || pop === 'history';
108
+ const toggle = () => editor.setPop(expanded ? null : 'waiting');
109
+ const n = status.waiting.length;
110
+ const pub = status.publish;
111
+ const common = { type: 'button' as const, 'data-slot': 'publish', 'aria-haspopup': 'dialog' as const, 'aria-expanded': expanded, onClick: toggle };
112
+ if (pub.running || pub.state === 'publishing') {
113
+ return (
114
+ <button {...common} className="pub busy" data-pub="publishing">
115
+ <span className="wt">Publishing…</span>
116
+ <span className="pb off">Publish</span>
117
+ <span className="prog" aria-hidden="true" />
118
+ </button>
119
+ );
120
+ }
121
+ if (pub.state === 'live' && !n && pub.record) {
122
+ return (
123
+ <button {...common} className="pub live" data-pub="live">
124
+ <span className="wt">
125
+ <Icon name="check" size="sm" />
126
+ Live · {clock(pub.record.at, status.timeZone)}
127
+ </span>
128
+ <span className="pb off">Publish</span>
129
+ </button>
130
+ );
131
+ }
132
+ if (pub.state === 'failed' && !n) {
133
+ return (
134
+ <button {...common} className="pub fail" data-pub="failed">
135
+ <span className="wt">
136
+ <Icon name="warn" size="sm" />
137
+ Publish failed
138
+ </span>
139
+ <span className={`pb${pub.canTryAgain ? '' : ' off'}`}>{pub.canTryAgain ? 'Try again' : 'Publish'}</span>
140
+ </button>
141
+ );
142
+ }
143
+ if (!n) {
144
+ return (
145
+ <button {...common} className="pub off" data-pub="nothing" aria-label="Nothing waiting. Nothing to publish">
146
+ <span className="wt">Nothing waiting</span>
147
+ <span className="pb">Publish</span>
148
+ </button>
149
+ );
150
+ }
151
+ const off = keep === 'offline' || !reachable || !!signedOut;
152
+ // M5b Decision 9: images without alt text put a warning dot on the count; Publish stays on.
153
+ const gaps = off ? 0 : altGaps(status.waiting);
154
+ const gw = `${gaps} image${gaps === 1 ? '' : 's'} without alt text`;
155
+ return (
156
+ <button {...common} className={`pub${off ? ' off' : ''}`} data-pub="waiting" data-alt-gaps={gaps || undefined} aria-label={`${n} change${n === 1 ? '' : 's'} waiting${gaps ? `, ${gw}` : ''}. Publish`}>
157
+ <span className="wt">
158
+ <span className="avs">
159
+ {people(status.waiting).map((p) => (
160
+ <Avatar key={p.email} person={p} size="sm" />
161
+ ))}
162
+ </span>
163
+ <b>{n} waiting</b>
164
+ </span>
165
+ <span className="pb">
166
+ Publish{' '}
167
+ <span className="count" title={gaps ? gw : undefined}>
168
+ {n}
169
+ {gaps ? <span className="wdot" aria-hidden="true" data-dot="alt" /> : null}
170
+ </span>
171
+ </span>
172
+ </button>
173
+ );
174
+ }
175
+
176
+ /** A click outside a popover closes it (its trigger toggles it itself). */
177
+ function useOutside(close: () => void, trigger: string) {
178
+ const ref = useRef<HTMLDivElement>(null);
179
+ useEffect(() => {
180
+ const onDown = (event: MouseEvent) => {
181
+ const target = event.target as Element | null;
182
+ if (!target || ref.current?.contains(target) || target.closest?.(trigger)) return;
183
+ close();
184
+ };
185
+ document.addEventListener('mousedown', onDown);
186
+ return () => document.removeEventListener('mousedown', onDown);
187
+ }, [close, trigger]);
188
+ return ref;
189
+ }
190
+
191
+ function HistoryLink() {
192
+ const editor = useEditor();
193
+ return (
194
+ <button type="button" className="linkbtn subtle" data-act="history" onClick={() => editor.setPop('history')}>
195
+ <Icon name="history" size="sm" />
196
+ History
197
+ </button>
198
+ );
199
+ }
200
+
201
+ export function WaitingPop() {
202
+ const editor = useEditor();
203
+ const status = useStore(editor.liveState, (s) => s.status);
204
+ const reachable = useStore(editor.liveState, (s) => s.reachable);
205
+ const signedOut = useStore(editor.liveState, (s) => s.signedOut);
206
+ const keep = useStore(editor.keep, (k) => k.state);
207
+ const [busy, setBusy] = useState(false);
208
+ const [tryError, setTryError] = useState<string | null>(null);
209
+ /** The last Publish press failed (a 502, the server away): said here, and Publish can be pressed again. */
210
+ const [publishError, setPublishError] = useState<string | null>(null);
211
+ const ref = useOutside(() => editor.setPop(null), '.pub');
212
+ // On intent: the history, so it's there when History is pressed.
213
+ useEffect(() => readHistory(editor), [editor]);
214
+ if (!status) return null;
215
+ const wrap = (body: ReactNode, width = 440) => (
216
+ <div ref={ref} className="pop" role="dialog" aria-label="Waiting changes and Publish" data-pop="waiting" style={{ right: 8, width }}>
217
+ {body}
218
+ </div>
219
+ );
220
+ const n = status.waiting.length;
221
+ const pub = status.publish;
222
+ const record = pub.record;
223
+ const names = joinNames(record?.titles ?? []);
224
+ const by = record ? displayName(record.by) : '';
225
+
226
+ if ((pub.running || pub.state === 'publishing') && !n) {
227
+ return wrap(
228
+ <>
229
+ <div className="pop-h">
230
+ <div>
231
+ <h2>Publishing</h2>
232
+ <p className="sub">{record ? `${names} going live.` : 'Going live.'}</p>
233
+ </div>
234
+ </div>
235
+ <div className="pop-b">
236
+ <div className="progress" role="progressbar" aria-label="Publishing" aria-valuetext="Publishing" />
237
+ {record ? (
238
+ <p className="field-help" style={{ marginBottom: 'var(--space-4)' }}>
239
+ Started at {clock(record.at, status.timeZone)} by {by}.
240
+ </p>
241
+ ) : null}
242
+ </div>
243
+ <div className="pop-f">
244
+ <HistoryLink />
245
+ <span className="sp" />
246
+ <button type="button" className="btn lg" disabled>
247
+ Publish
248
+ </button>
249
+ </div>
250
+ </>,
251
+ );
252
+ }
253
+ if (pub.state === 'live' && !n && record) {
254
+ return wrap(
255
+ <>
256
+ <div className="pop-h">
257
+ <div>
258
+ <h2 style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--status-live)' }}>
259
+ <Icon name="check" />
260
+ Live
261
+ </h2>
262
+ <p className="sub">
263
+ Published at {clock(record.at, status.timeZone)} by {by}: {names}.
264
+ </p>
265
+ </div>
266
+ </div>
267
+ <div className="pop-b">
268
+ <div className="empty" style={{ marginBottom: 'var(--space-4)' }}>
269
+ <b>Nothing waiting</b>
270
+ </div>
271
+ </div>
272
+ <div className="pop-f">
273
+ <HistoryLink />
274
+ <span className="sp" />
275
+ <a className="btn" href="/" target="_blank" rel="noopener">
276
+ <Icon name="ext" size="sm" />
277
+ View the live site
278
+ </a>
279
+ </div>
280
+ </>,
281
+ );
282
+ }
283
+ if (pub.state === 'failed' && !n) {
284
+ return wrap(
285
+ <>
286
+ <div className="pop-h">
287
+ <div>
288
+ <h2 style={{ color: 'var(--status-danger)' }}>The publish didn't go live</h2>
289
+ <p className="sub">
290
+ {record ? `${names} ${record.titles.length === 1 ? 'was' : 'were'} published at ${clock(record.at, status.timeZone)}, but` : 'The'} the live site couldn't be updated.
291
+ </p>
292
+ </div>
293
+ </div>
294
+ <div className="pop-b">
295
+ {pub.canTryAgain ? (
296
+ <div className="note danger" data-note="try-again">
297
+ <Icon name="warn" />
298
+ <div>
299
+ <b>Try again updates the live site</b>
300
+ <p>If it fails twice, tell your developer.</p>
301
+ {tryError ? <p>It didn't work: {tryError}</p> : null}
302
+ </div>
303
+ </div>
304
+ ) : (
305
+ <div className="note danger" data-note="no-token">
306
+ <Icon name="warn" />
307
+ <div>
308
+ <b>A developer can redeploy it from Railway</b>
309
+ </div>
310
+ </div>
311
+ )}
312
+ </div>
313
+ <div className="pop-f">
314
+ <HistoryLink />
315
+ <span className="sp" />
316
+ {pub.canTryAgain ? (
317
+ <button
318
+ type="button"
319
+ className="btn lg ink"
320
+ data-act="try-again"
321
+ disabled={busy}
322
+ onClick={async () => {
323
+ setBusy(true);
324
+ setTryError(null);
325
+ const r = await editor.tryAgain();
326
+ setBusy(false);
327
+ if (!r.ok) setTryError(r.error ?? 'the server said no.');
328
+ }}
329
+ >
330
+ Try again
331
+ </button>
332
+ ) : null}
333
+ </div>
334
+ </>,
335
+ );
336
+ }
337
+ if (!n) {
338
+ return wrap(
339
+ <>
340
+ <div className="pop-h">
341
+ <div>
342
+ <h2>Nothing to publish</h2>
343
+ <p className="sub">Everything is live.</p>
344
+ </div>
345
+ </div>
346
+ <div className="pop-f">
347
+ <HistoryLink />
348
+ <span className="sp" />
349
+ <button type="button" className="btn lg" disabled>
350
+ Publish
351
+ </button>
352
+ </div>
353
+ </>,
354
+ 380,
355
+ );
356
+ }
357
+ if (keep === 'offline' || !reachable || signedOut) {
358
+ return wrap(
359
+ <>
360
+ <div className="pop-h">
361
+ <div>
362
+ <h2>
363
+ {n} change{n === 1 ? '' : 's'} waiting
364
+ </h2>
365
+ <p className="sub">
366
+ {signedOut ? 'Publishing needs you signed in.' : 'Publishing needs the server.'}
367
+ </p>
368
+ </div>
369
+ </div>
370
+ <div className="pop-f">
371
+ <HistoryLink />
372
+ <span className="sp" />
373
+ <button type="button" className="btn lg" disabled>
374
+ Publish
375
+ </button>
376
+ </div>
377
+ </>,
378
+ 380,
379
+ );
380
+ }
381
+ return wrap(
382
+ <>
383
+ <div className="pop-h">
384
+ <div>
385
+ <h2>
386
+ {n} change{n === 1 ? '' : 's'} waiting
387
+ </h2>
388
+ </div>
389
+ <span className="r">{whoCounts(status.waiting)}</span>
390
+ </div>
391
+ <div className="pop-b">
392
+ <ul className="wlist">
393
+ {status.waiting.map((w) => (
394
+ <WaitItem key={w.file} item={w} status={status} />
395
+ ))}
396
+ </ul>
397
+ {publishError ? (
398
+ <div className="note danger tight" role="alert" data-note="publish-failed">
399
+ <Icon name="warn" />
400
+ <div>
401
+ <b>It didn't publish</b>
402
+ <p>Nothing went live. ({publishError})</p>
403
+ </div>
404
+ </div>
405
+ ) : null}
406
+ </div>
407
+ <div className="pop-f">
408
+ <HistoryLink />
409
+ <span className="sp" />
410
+ <button
411
+ type="button"
412
+ className="btn lg accent"
413
+ data-act="publish"
414
+ disabled={busy}
415
+ onClick={async () => {
416
+ setBusy(true);
417
+ setPublishError(null);
418
+ const r = await editor.publish();
419
+ setBusy(false);
420
+ if (!r.ok) setPublishError(r.error ?? 'the server said no.');
421
+ }}
422
+ >
423
+ Publish {n} change{n === 1 ? '' : 's'}
424
+ </button>
425
+ </div>
426
+ </>,
427
+ );
428
+ }
429
+
430
+ function whoCounts(waiting: readonly WaitingView[]): string {
431
+ const counts = new Map<string, { person: Person; n: number }>();
432
+ for (const w of waiting)
433
+ for (const e of w.editors) {
434
+ const k = e.person.email.toLowerCase();
435
+ const c = counts.get(k) ?? { person: e.person, n: 0 };
436
+ c.n += 1;
437
+ counts.set(k, c);
438
+ }
439
+ return [...counts.values()]
440
+ .sort((a, b) => b.n - a.n)
441
+ .map((c) => `${displayName(c.person)} ${c.n}`)
442
+ .join(' · ');
443
+ }
444
+
445
+ function WaitItem({ item, status }: { item: WaitingView; status: StatusResponse }) {
446
+ const editor = useEditor();
447
+ const others = item.editingNow.filter((p) => !samePerson(p, status.you));
448
+ const editing = item.editingNow.length > 0;
449
+ const notice = status.notices.find((n) => n.kind === 'draft-kept' && n.file === item.file);
450
+ const collection = editor.site.collections.find((c) => c.name === item.collection);
451
+ return (
452
+ <li className="wi" data-waiting={item.file}>
453
+ <div className="wi-top">
454
+ <Icon name={collection ? docIcon(collection) : 'page'} />
455
+ <b>{item.title}</b>
456
+ {editing ? <span className="chip waiting">Editing now</span> : null}
457
+ {/* M5a: a document that isn't on the live site yet, or that leaves it at this Publish. */}
458
+ {item.change === 'deleted' ? (
459
+ <span className="chip danger" data-change="deleted">
460
+ Deleted
461
+ </span>
462
+ ) : item.movedFrom ? (
463
+ <span className="chip waiting" data-change="moved">
464
+ Moved
465
+ </span>
466
+ ) : item.change === 'new' ? (
467
+ <span className="chip waiting" data-change="new">
468
+ New {collection ? singular(collection) : 'page'}
469
+ </span>
470
+ ) : null}
471
+ <span className="when">
472
+ {item.editors.map((e) => (
473
+ <Avatar key={e.person.email} person={e.person} size="sm" />
474
+ ))}
475
+ {item.editors.map((e) => displayName(e.person)).join(', ')}
476
+ {item.changedAt ? ` · ${when(item.changedAt, status.timeZone, status.now)}` : ''}
477
+ </span>
478
+ </div>
479
+ {item.change === 'deleted' ? <div className="wi-what">It leaves the live site at the next Publish.</div> : null}
480
+ {item.movedFrom ? (
481
+ <div className="wi-what" data-moved-from={item.movedFrom}>
482
+ {item.movedRedirect ? `${item.movedFrom} redirects here.` : `Moved from ${item.movedFrom}.`}
483
+ </div>
484
+ ) : null}
485
+ {collection?.kind === 'settings' ? (
486
+ <div className="chips">
487
+ <span className="chip live" data-chip="applies">
488
+ <Icon name="bolt" size="xs" />
489
+ Sign-in and branding already apply
490
+ </span>
491
+ </div>
492
+ ) : null}
493
+ {others.length ? (
494
+ <div className="wi-what">
495
+ {joinNames(others.map(displayName))} {others.length === 1 ? 'is' : 'are'} still in it: it goes out as it stands, and they're told.
496
+ </div>
497
+ ) : null}
498
+ {notice ? (
499
+ <div className="note warn tight" data-note="draft-kept">
500
+ <Icon name="warn" />
501
+ <div>
502
+ <b>{item.title} was also changed outside the editor.</b>
503
+ <p>Your draft was kept; check it before publishing.</p>
504
+ </div>
505
+ </div>
506
+ ) : null}
507
+ {item.change !== 'deleted' && item.missingAlt?.length ? <AltGaps item={item} held={others} /> : null}
508
+ </li>
509
+ );
510
+ }
511
+
512
+ /** One waiting document's images without alt text (#publish-alt), each with Add alt text. It doesn't hold the Publish back. */
513
+ function AltGaps({ item, held }: { item: WaitingView; held: Person[] }) {
514
+ const editor = useEditor();
515
+ const gaps = item.missingAlt;
516
+ const fix = (gap: MissingAltView) => (
517
+ <button type="button" className="btn sm" data-act="fix-alt" data-alt-path={gap.path} onClick={() => void openAtAlt(editor, item, gap, { held: held.length > 0 })}>
518
+ Add alt text
519
+ </button>
520
+ );
521
+ const heldLine = held.length ? (
522
+ <p data-held="">
523
+ {joinNames(held.map(displayName))} {held.length === 1 ? 'is' : 'are'} editing {item.title}, so it opens read-only for now.
524
+ </p>
525
+ ) : null;
526
+ return (
527
+ <div className="note warn tight" data-slot="alt-text" data-note="alt-text">
528
+ <Icon name="image" />
529
+ {gaps.length === 1 ? (
530
+ <div>
531
+ <b>{gaps[0]!.label} has no alt text</b>
532
+ <p>Doesn't block Publish.</p>
533
+ {heldLine}
534
+ <div className="acts">{fix(gaps[0]!)}</div>
535
+ </div>
536
+ ) : (
537
+ <div>
538
+ <b>{gaps.length} images have no alt text</b>
539
+ <p>Doesn't block Publish.</p>
540
+ {heldLine}
541
+ <div className="alt-rows">
542
+ {gaps.map((gap) => (
543
+ <div key={gap.path + (gap.block ?? '')} className="alt-row" data-alt-row={gap.path}>
544
+ <span className="t">{gap.label}</span>
545
+ {fix(gap)}
546
+ </div>
547
+ ))}
548
+ </div>
549
+ </div>
550
+ )}
551
+ </div>
552
+ );
553
+ }
554
+
555
+ export function HistoryPop() {
556
+ const editor = useEditor();
557
+ const [history, setHistory] = useState(() => histories.get(editor) ?? null);
558
+ const status = useStore(editor.liveState, (s) => s.status);
559
+ const ref = useOutside(() => editor.setPop(null), '.pub');
560
+ // Read again when opened (at most once a minute on the server); the last read shows meanwhile.
561
+ useEffect(() => readHistory(editor, setHistory), [editor]);
562
+ const timeZone = history?.timeZone ?? status?.timeZone ?? 'UTC';
563
+ const now = status?.now ?? new Date().toISOString();
564
+ const entries = history?.entries ?? [];
565
+ return (
566
+ <div ref={ref} className="pop" role="dialog" aria-label="Publish history" data-pop="history" style={{ right: 8, width: 480 }}>
567
+ <div className="pop-h">
568
+ <button type="button" className="ib" aria-label="Back to waiting changes" onClick={() => editor.setPop('waiting')}>
569
+ <Icon name="chev-l" />
570
+ </button>
571
+ <div>
572
+ <h2>Publish history</h2>
573
+ <p className="sub">{zoneLine(timeZone)}</p>
574
+ </div>
575
+ </div>
576
+ <div className="pop-b">
577
+ {history && entries.length === 0 ? (
578
+ <div className="empty" style={{ marginBottom: 'var(--space-4)' }}>
579
+ <b>Nothing published yet</b>
580
+ </div>
581
+ ) : (
582
+ <table className="hist">
583
+ <tbody>
584
+ {entries.map((h) => (
585
+ <tr key={h.commit} data-history={h.commit}>
586
+ <td className="when">{when(h.at, timeZone, now)}</td>
587
+ <td className="who">{h.by ? <span><Avatar person={h.by} size="sm" />{displayName(h.by)}</span> : null}</td>
588
+ <td className="docs">{joinNames(h.titles)}</td>
589
+ <td className="st">
590
+ {h.state === 'live' ? (
591
+ <span className="chip live">
592
+ <Icon name="check" size="xs" />
593
+ Live
594
+ </span>
595
+ ) : h.state === 'failed' ? (
596
+ <span className="chip danger">
597
+ <Icon name="warn" size="xs" />
598
+ Failed
599
+ </span>
600
+ ) : (
601
+ <span className="chip waiting">Publishing</span>
602
+ )}
603
+ </td>
604
+ </tr>
605
+ ))}
606
+ </tbody>
607
+ </table>
608
+ )}
609
+ </div>
610
+ {history ? null : (
611
+ <div className="pop-f">
612
+ <span className="fine">Reading the history…</span>
613
+ </div>
614
+ )}
615
+ </div>
616
+ );
617
+ }