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,881 @@
1
+ /**
2
+ * Types the editor's server routes and its browser code share. Types only: the
3
+ * browser imports nothing from config.ts or content.ts at run time (they read the
4
+ * disk), only document.ts and fields.ts, which are pure.
5
+ */
6
+ import type { Brand, Fields, ResolvedBlock } from '../config.ts';
7
+ import type { DocumentEntry } from '../content.ts';
8
+ import type { AnyDocument, DocumentBlock, DocumentKind, SiteDocument } from '../document.ts';
9
+ import type { Person } from '../live/messages.ts';
10
+ import type { ResolvedFormField } from '../forms/rules.ts';
11
+ import type { CodeRoute } from '../code-routes.ts';
12
+ import type { SubmissionResponse, SubmissionsListResponse, SubmissionsSetup } from '../forms/inbox.ts';
13
+
14
+ // M6 Decision 9: the inbox's routes and shapes (forms/inbox.ts), for the client.
15
+ export type { SubmissionRow, SubmissionsListResponse, SubmissionResponse, SubmissionsSetup, SubmissionsUnset } from '../forms/inbox.ts';
16
+ export type { EmailOutcome, StoredFile, StoredSubmission } from '../forms/store.ts';
17
+
18
+ export type { Person };
19
+
20
+ /** A document's identity: its collection and its path in it (content.ts → DocumentEntry). */
21
+ export interface DocRef {
22
+ collection: string;
23
+ path: string;
24
+ }
25
+
26
+ /** `pages/services/branding`: a document's id, for keys, storage and the Recent list. */
27
+ export const docId = (ref: DocRef): string => `${ref.collection}/${ref.path}`;
28
+
29
+ export const sameDoc = (a: DocRef | null | undefined, b: DocRef | null | undefined): boolean =>
30
+ !!a && !!b && a.collection === b.collection && a.path === b.path;
31
+
32
+ /**
33
+ * A collection as the editor's screens know it (M5a Phase 5: every kind). Kept as its
34
+ * own name for the code that only reads `name`, `label`, `urlBase` and `nested`.
35
+ */
36
+ export type EditorCollection = EditorAnyCollection;
37
+
38
+ /**
39
+ * Any collection as the browser knows it (M5a): the site's section builders and
40
+ * list-of-fields collections, then the reserved `shared` and `settings` (Decision 8).
41
+ * `urlBase` is null for the reserved two; `fields` is a fields collection's (and
42
+ * Site Settings' site-added) fields, labelled.
43
+ */
44
+ export interface EditorAnyCollection {
45
+ name: string;
46
+ label: string;
47
+ kind: 'sections' | 'fields' | 'shared' | 'settings';
48
+ urlBase: string | null;
49
+ nested: boolean;
50
+ fields: Fields | null;
51
+ /** The table's columns after the title, as configured; null or absent for the default (pre-M7 polish; columns.ts). */
52
+ columns?: string[] | null;
53
+ /** The view's initial order (posts and tags, Decision 13): `title` or a column, either way; null or absent for the title, A to Z. */
54
+ sort?: import('../columns.ts').SortConfig | null;
55
+ /** A section builder's offered blocks (config.ts); null or absent for every block. */
56
+ blocks?: string[] | null;
57
+ }
58
+
59
+ /**
60
+ * The brand as the editor's chrome shows it (M2's four slots, all Site Settings': M5a
61
+ * Decision 10, M5b Decision 11). `tabIcon` is the tab's icon as it applies: the icon,
62
+ * else the logo, else the neutral mark (the initial on the accent), so the client
63
+ * follows a change without working it out (brand.ts → editorBrand).
64
+ */
65
+ export type EditorBrand = Brand & { accentInk: string; accentEdge: string; tabIcon?: string };
66
+
67
+ /** What the browser knows about the site: the resolved config minus the server's absolute paths. */
68
+ export interface EditorSite {
69
+ /**
70
+ * Every collection, of every kind, in config order with the reserved `shared` and
71
+ * `settings` last (M5a Phase 5: the editor opens all four kinds). Use `kind` to
72
+ * tell them apart: the documents level lists `sections` and `fields` collections
73
+ * under Pages and Collections, and the reserved two under Site-wide.
74
+ */
75
+ collections: EditorAnyCollection[];
76
+ /** The same list as `collections` (M5a Phase 2's name for it, kept). */
77
+ allCollections: EditorAnyCollection[];
78
+ /** Block type → its schema (the shape document.ts's parse helpers take). `component` is relative. */
79
+ blocks: Record<string, ResolvedBlock>;
80
+ /** Text style → what the editor's warnings read. */
81
+ textStyles: Record<string, { name: string; sizePx: number; minSizePx: number }>;
82
+ /**
83
+ * The four brand slots (M2), with the accent's ink and edge computed by contrast
84
+ * (brand.ts). Name and accent are Site Settings' as committed (M5a Decision 10), as
85
+ * at first paint; in live mode the status poll carries changes (`StatusResponse.brand`).
86
+ */
87
+ brand: EditorBrand;
88
+ phoneLinesWidth: number;
89
+ /** The document `/admin` opens: Home. */
90
+ home: DocRef;
91
+ /** The dev server's host, for the too-narrow note's address. */
92
+ host: string;
93
+ /** Keeps this site's unsent edits apart from another site's on the same dev port (a hash of its root). */
94
+ storageKey: string;
95
+ /** M6: the site's time zone (`timeZone` in the config), for the inbox's times in local mode (live mode's status has it too). */
96
+ timeZone?: string;
97
+ /** M8 Decision 10: the URLs the site's own `src/pages/` serves, which a page can't take (code-routes.ts). */
98
+ codeRoutes?: CodeRoute[];
99
+ }
100
+
101
+ /**
102
+ * A listed document, with its file's modification time (null if unreadable). M5a:
103
+ * every kind of document is listed, with `kind`, its public `url` (null for a shared
104
+ * block or Site Settings), a section builder's shared entries (`uses`), and a shared
105
+ * block's `blockType` and `usedOn` (content.ts → DocumentEntry).
106
+ */
107
+ export interface ListedDocument extends DocumentEntry {
108
+ mtimeMs: number | null;
109
+ }
110
+
111
+ /**
112
+ * A document as the editor opens it (GET /_caracms/document). `D` is its document's
113
+ * shape: a section builder's by default (what the editor's screens open today); the
114
+ * route answers any kind (`AnyOpenedDocument`, M5a).
115
+ */
116
+ export interface OpenedDocument<D extends AnyDocument = SiteDocument> {
117
+ entry: DocumentEntry;
118
+ /** The file's text as read: the serializer's base for the editing session. */
119
+ text: string;
120
+ document: D;
121
+ mtimeMs: number;
122
+ /**
123
+ * A short hash of `text`: the file's version. Unsent edits remember the version of
124
+ * the file they were made against (keeper.ts → "newer than its file").
125
+ */
126
+ version: string;
127
+ /** Live mode (M4 Decision 11): who last changed it, for re-sending held edits. Absent in local mode. */
128
+ lastChange?: LastChange;
129
+ }
130
+
131
+ /**
132
+ * GET /_caracms/document for any kind (M5a). `document` is, by `entry.kind`:
133
+ * - `sections`: `{ title, blocks }` (a shared entry is a block with `shared`, never known);
134
+ * - `fields`: `{ kind: 'fields', title, data }`, every schema field parsed;
135
+ * - `shared`: `{ kind: 'shared', title, block }`;
136
+ * - `settings`: `{ kind: 'settings', title, data: { editors, brand: { name, accent }, …the site's own } }`.
137
+ * The write route takes the same shapes back (`{ file, base, document }`).
138
+ */
139
+ export type AnyOpenedDocument = OpenedDocument<AnyDocument>;
140
+
141
+ /** Per-browser state the sidebar paints at first paint, kept in a cookie (Decision 20; ui-state.ts). */
142
+ export interface UiState {
143
+ /** Pages groups (and pages with children) shown open, by `<collection>:<path>`. Absent: never touched. */
144
+ open: Record<string, boolean>;
145
+ /** Recently opened documents, newest first, by docId. */
146
+ recent: string[];
147
+ /** The sidebar hidden (pre-M7 polish: the bar's button and ⌘\\). Absent: shown. */
148
+ side?: 'hidden';
149
+ }
150
+
151
+ /** Everything /admin server-renders from, and hydrates with. */
152
+ export interface AdminProps {
153
+ site: EditorSite;
154
+ documents: ListedDocument[];
155
+ /** The open document, of any kind (M5a). */
156
+ opened: AnyOpenedDocument;
157
+ ui: UiState;
158
+ /** This browser holds unsent edits for the site (a cookie, ui-state.ts): the bar starts at Keeping. */
159
+ unsent: boolean;
160
+ /**
161
+ * M4: live mode's start (M4 Decision 13): who is signed in, and the status as the
162
+ * server read it for this page (no tab yet, so a lock of yours reads as another
163
+ * tab's until the first poll). Null or absent: local mode, which never polls and
164
+ * shows none of M4's places (M3's editor, Decision 1).
165
+ */
166
+ live?: LiveProps | null;
167
+ /**
168
+ * M5b: whether the media routes are set up here, read as /admin renders (Decision 5),
169
+ * so a media field shows the 503's sentence in its first paint. Absent: unknown (the
170
+ * field finds out from the routes).
171
+ */
172
+ media?: MediaSetup;
173
+ /**
174
+ * M6 Decision 9: the address opened the inbox (`/admin/submissions`, `{ id: null }`) or a
175
+ * submission in it (`/admin/submissions/<id>`, the email's link). Null or absent: a
176
+ * document's address. `opened` is then Home, the page under the inbox.
177
+ */
178
+ inbox?: { id: string | null } | null;
179
+ /**
180
+ * M6 Decision 9: the unread count for the Site-wide row, read as /admin renders; or,
181
+ * when the production server has no submissions bucket, the sentence naming what's
182
+ * missing (`submissions-off`). The documents route carries the count too, in its
183
+ * `X-Caracms-Unread` header.
184
+ */
185
+ submissions?: SubmissionsSetup;
186
+ /** M6: the site's forms (id, label, fields in order), for Site Settings' Form emails and the Form block's picker. */
187
+ forms?: EditorForm[];
188
+ /**
189
+ * M6: with `inbox`, its first page and the submission the address names, read as /admin
190
+ * renders, so the list and the sheet are in the first paint. Null parts are read by the browser.
191
+ */
192
+ inboxFirst?: { list: SubmissionsListResponse | null; open: SubmissionResponse | null } | null;
193
+ }
194
+
195
+ /** One of the site's forms, as the editor shows it (M6). */
196
+ export interface EditorForm {
197
+ id: string;
198
+ label: string;
199
+ fields: ResolvedFormField[];
200
+ }
201
+
202
+ export interface LiveProps {
203
+ you: Person;
204
+ /** Null when the server couldn't read it (Redis out): the first poll fills it. */
205
+ status: StatusResponse | null;
206
+ /** How often the tab polls (live timings' `pollMs`: POLL_MS, shorter only in tests). */
207
+ pollMs?: number;
208
+ }
209
+
210
+ /** A block in the editor: a DocumentBlock plus a key that stays with it through moves (never written). */
211
+ export interface EditorBlock extends DocumentBlock {
212
+ key: string;
213
+ }
214
+
215
+ /**
216
+ * The open document in the editor's store (document-store.ts). Every kind is held as a
217
+ * list of blocks, so every field is set the same way (by a key and a path):
218
+ * - `sections`: the page's blocks;
219
+ * - `fields` and `settings`: one block, the form (its `data` is the document's
220
+ * `data`; `type` '' and `known`), never written as a block;
221
+ * - `shared`: one block, the shared block itself.
222
+ */
223
+ export interface EditorDocument {
224
+ kind: DocumentKind;
225
+ title: string;
226
+ blocks: EditorBlock[];
227
+ /**
228
+ * M6 Decision 13: a section builder's or a fields document's own `seo` (title,
229
+ * description, image), as edited; absent until the file has it or something is set.
230
+ * Set through `setField(SEO_SCOPE, …)` (document-store.ts).
231
+ */
232
+ seo?: Record<string, unknown>;
233
+ /**
234
+ * M6c (G2): a section builder's page fields, as edited; present when its collection
235
+ * has page fields. Set through `setField(PAGE_SCOPE, …)` (document-store.ts).
236
+ */
237
+ data?: Record<string, unknown>;
238
+ /** M9 Decision 7: a section builder hidden from the site; absent when the file isn't. Set through `setHidden`. */
239
+ hidden?: boolean;
240
+ }
241
+
242
+ /** The preview widths (M2 Decision 3): each iframe at its true width. */
243
+ export type Width = 'desktop' | 'tablet' | 'phone';
244
+ export const WIDTHS: readonly Width[] = ['desktop', 'tablet', 'phone'];
245
+ export const TRUE_WIDTH: Record<Width, number> = { desktop: 1440, tablet: 768, phone: 375 };
246
+ export const WIDTH_LABEL: Record<Width, string> = { desktop: 'Desktop', tablet: 'Tablet', phone: 'Phone' };
247
+
248
+ /**
249
+ * The attribute on a preview's `<html>` (M8 review): the editor's middleware adds it to the
250
+ * preview route's 200 HTML, so a frame that loaded an error page (a 404, a 401, a 500) can
251
+ * tell it isn't a preview (preview/driver.ts).
252
+ */
253
+ export const PREVIEW_MARK = 'data-caracms-preview';
254
+
255
+ /** The editor's endpoints. M4: injected in dev and in the build; live mode needs a session (Decision 14). */
256
+ export const ENDPOINTS = {
257
+ preview: '/_caracms/preview',
258
+ document: '/_caracms/document',
259
+ documents: '/_caracms/documents',
260
+ write: '/_caracms/write',
261
+ // M5a Phase 3: creating and deleting documents, both modes (Decisions 11 and 12).
262
+ create: '/_caracms/create',
263
+ delete: '/_caracms/delete',
264
+ // Pre-M7 item 8: changing a document's address, both modes.
265
+ move: '/_caracms/move',
266
+ // M5b Phase 2: the media routes, both modes (MEDIA_ENDPOINTS below has each one).
267
+ media: '/_caracms/media',
268
+ // M4 Phase 2 (live mode only; local mode answers 404).
269
+ status: '/_caracms/status',
270
+ lock: '/_caracms/lock',
271
+ publish: '/_caracms/publish',
272
+ history: '/_caracms/history',
273
+ tryAgain: '/_caracms/try-again',
274
+ notice: '/_caracms/notice',
275
+ // M6 Decision 9: the inbox, both modes (forms/inbox.ts has each route).
276
+ submissions: '/_caracms/submissions',
277
+ } as const;
278
+
279
+ /** M6: the unread count on the documents route's response and the inbox's 204s (forms/inbox.ts → UNREAD_HEADER). */
280
+ export const UNREAD_HEADER = 'X-Caracms-Unread';
281
+
282
+ // ── Live mode's answers (M4 Phase 2), for Phase 4's client ─────────────────────
283
+
284
+ /** Every editor request from a tab carries its id in this header (Decision 4). */
285
+ export const TAB_HEADER = 'X-Caracms-Tab';
286
+ /** `1` on the closing tab's last write: release the lock once it's committed (Decision 9). */
287
+ export const RELEASE_HEADER = 'X-Caracms-Release';
288
+ /**
289
+ * On every write: the tab's own write sequence, rising with each write (never lower than
290
+ * the last, across reloads too: it starts from the clock). The server never lets a write
291
+ * with a lower one replace the same tab's newer autosave.
292
+ */
293
+ export const SEQ_HEADER = 'X-Caracms-Seq';
294
+
295
+ /** An error answer: a plain sentence, and a code for the states the editor tells apart. */
296
+ export interface ApiError {
297
+ error: string;
298
+ /** `signed-out` (401), `locked` (409), `unavailable` (503: Redis or GitHub out past its budget), `no-tab` (400), `not-live` (404 in local mode). */
299
+ code?: string;
300
+ }
301
+
302
+ /**
303
+ * Who last changed a document, and the version that change left (Decision 11). A merge
304
+ * from `main`, or the sweep committing someone's own autosave, doesn't change `by`.
305
+ * `by` null (and `known` not false): only merges from `main` are known. `version` null:
306
+ * known from git only (after a flush), so compare by text.
307
+ */
308
+ export interface LastChange {
309
+ /**
310
+ * False: the server can't tell who last changed it (no record, and git names nobody:
311
+ * never edited here, or Redis lost its data). The keeper treats that as someone
312
+ * else's change (held, Use mine / Discard mine), never a silent re-send. Absent: known.
313
+ */
314
+ known?: boolean;
315
+ by: Person | null;
316
+ /** fileVersion of the text that editor's autosave left. */
317
+ version: string | null;
318
+ /** ISO. */
319
+ at: string | null;
320
+ }
321
+
322
+ /** 401 from any editor API in live mode with no session (Phase 3's guard, Decision 11). */
323
+ export interface SignedOutError extends ApiError {
324
+ code: 'signed-out';
325
+ /** `removed`: the cookie was valid, but the address is no longer on the whitelist. */
326
+ reason: 'signed-out' | 'removed';
327
+ /** Where to sign in again (opened in a new tab). */
328
+ signIn: string;
329
+ }
330
+
331
+ /** A document's lock as a tab sees it (Decision 9). */
332
+ export interface LockView {
333
+ file: string;
334
+ person: Person;
335
+ /** ISO: when it was taken. */
336
+ since: string;
337
+ /** The holder's tab is closing: the lock drops once its autosave is committed. */
338
+ releasing: boolean;
339
+ /** `this-tab`: the asking tab holds it. `other-tab`: the same person, another tab (Edit here). null: someone else. */
340
+ mine: 'this-tab' | 'other-tab' | null;
341
+ }
342
+
343
+ /** The open document's lock, from the asking tab's side. */
344
+ export type LockState = 'free' | 'yours' | 'yours-elsewhere' | 'theirs';
345
+
346
+ /** 409 from the write route or `POST /_caracms/lock` (take): another tab holds the lock. */
347
+ export interface LockRefusal extends ApiError {
348
+ code: 'locked';
349
+ lock: LockView;
350
+ state: 'yours-elsewhere' | 'theirs';
351
+ lastChange: LastChange;
352
+ }
353
+
354
+ /**
355
+ * What a waiting document is to `main` (M5a Decisions 11 and 12): `new` (main doesn't
356
+ * have it), `deleted` (main has it and the draft removes it), or `changed`. A document
357
+ * created and deleted again before a Publish isn't waiting at all.
358
+ */
359
+ export type WaitingChange = 'changed' | 'new' | 'deleted';
360
+
361
+ export interface WaitingView {
362
+ file: string;
363
+ /** New, Deleted, or changed (M5a). For `deleted`, `editors[0]` is who deleted it. */
364
+ change: WaitingChange;
365
+ /** The document's collection and path, when the file is a document. */
366
+ collection: string | null;
367
+ path: string | null;
368
+ title: string;
369
+ /** Who changed it since the last publish, most recent first (committed and uncommitted). */
370
+ editors: { person: Person; at: string }[];
371
+ changedAt: string | null;
372
+ /** An autosave not yet in `drafts`. */
373
+ uncommitted: boolean;
374
+ /** The merge kept the draft for a key or the file (Decision 7's notice). */
375
+ keptDraft: boolean;
376
+ /** Who holds its lock, when that isn't the asking tab (another person, or your own other tab): "Editing now". Empty otherwise. */
377
+ editingNow: Person[];
378
+ /**
379
+ * M5b Decision 9: each image in it with no alt text that isn't decorative, read from the
380
+ * document as it will publish (the `drafts` copy with autosaves on top). Only rendered
381
+ * slots count (no unknown or skipped blocks, no shared entry on a page, no brand logo or
382
+ * icon); a deleted document has none. Publish's summary names them; nothing blocks.
383
+ */
384
+ missingAlt: MissingAltView[];
385
+ /**
386
+ * Pre-M7 item 8: a document whose address changed, on its new file's entry: its old
387
+ * address. The old file's own "deleted" entry isn't listed (it's the same document).
388
+ */
389
+ movedFrom?: string;
390
+ /** With `movedFrom`: Site Settings' redirects send visitors from the old address here (the editor may have taken it out). */
391
+ movedRedirect?: boolean;
392
+ }
393
+
394
+ /**
395
+ * One image with no alt text in a waiting document (M5b Decision 9), as Publish's summary
396
+ * names it ("Gallery image 3 has no alt text") and Add alt text opens it:
397
+ * `editor.openDocument({ collection, path })`, then `openBlock(doc.keyAt(block) ?? formKey, field)`,
398
+ * expanding `row` when it's in a list.
399
+ */
400
+ export interface MissingAltView {
401
+ /** The block's index in a section builder's `blocks` (0 for a shared block's own document); null for a fields document or Site Settings (their form), and for a section builder's page fields (M6c G2). */
402
+ block: number | null;
403
+ /** The slot's top-level field in the block or form: `media`, `images`, `cover`. */
404
+ field: string;
405
+ /** The dotted path to the empty `alt`, from the block's (or form's) data: `media.alt`, `images.2.alt`, `cover.alt`. */
406
+ path: string;
407
+ /** The list row it's in (0-based, as the editor's rows; the outer row, for an inner list's image: M7), or null when the slot isn't in a list. */
408
+ row: number | null;
409
+ /** The slot's label: "Hero image", "Text and media image", "Gallery image 3", "Cover image". */
410
+ label: string;
411
+ }
412
+
413
+ export interface PresenceView {
414
+ file: string;
415
+ people: Person[];
416
+ }
417
+
418
+ export type NoticeView =
419
+ /** "Home was also changed outside the editor. Your draft was kept; check it before publishing" (Decision 7). */
420
+ | { id: string; kind: 'draft-kept'; file: string; title: string }
421
+ /** "Aoife published Home as it stood at 14:20" (Decision 10), for the people who had it open. */
422
+ | { id: string; kind: 'published-as-it-stood'; file: string; title: string; by: Person; at: string };
423
+
424
+ export interface PublishRecordView {
425
+ commit: string;
426
+ by: Person;
427
+ at: string;
428
+ files: string[];
429
+ titles: string[];
430
+ tryAgainAt: string | null;
431
+ }
432
+
433
+ export interface PublishView {
434
+ /** `none`: nothing published since the record began. */
435
+ state: 'none' | 'publishing' | 'live' | 'failed';
436
+ /** A Publish is running now (committing, or moving the refs), before any record. */
437
+ running: boolean;
438
+ record: PublishRecordView | null;
439
+ /** Live only because there's no deploy pipeline (Decision 12). */
440
+ assumed: boolean;
441
+ /** Failed, and Try again can redeploy. */
442
+ canTryAgain: boolean;
443
+ /** CARACMS_RAILWAY_TOKEN is set. Failed with no token: "a developer can redeploy from Railway". */
444
+ hasToken: boolean;
445
+ }
446
+
447
+ /** GET /_caracms/status?file=<open document's file> (Decision 13). */
448
+ export interface StatusResponse {
449
+ mode: 'live';
450
+ you: Person;
451
+ tab: string | null;
452
+ /** Server time, ISO. */
453
+ now: string;
454
+ /** IANA zone for every time shown (Decision 16). */
455
+ timeZone: string;
456
+ /** The open document as the asking tab sees it; null when the poll named none. */
457
+ open: {
458
+ file: string;
459
+ state: LockState;
460
+ lock: LockView | null;
461
+ lastChange: LastChange;
462
+ /**
463
+ * M5a Decision 12: the document doesn't exist any more (deleted, or never there):
464
+ * who deleted it and when, when that's known (`by` null: not known). Null while it exists.
465
+ */
466
+ deleted: { by: Person | null; at: string | null } | null;
467
+ } | null;
468
+ waiting: WaitingView[];
469
+ locks: LockView[];
470
+ presence: PresenceView[];
471
+ notices: NoticeView[];
472
+ publish: PublishView;
473
+ /**
474
+ * The brand as committed in Site Settings (M5a Decision 10: read from `drafts`, never
475
+ * an autosave), so the chrome picks up a change on the next poll with no reload.
476
+ */
477
+ brand: EditorBrand;
478
+ }
479
+
480
+ /** POST /_caracms/lock `{ file, action }`. */
481
+ export interface LockRequest {
482
+ file: string;
483
+ /** `take`: Edit here (moves your own lock at once, or takes a free one). `release`: a closing tab with nothing unsent. */
484
+ action: 'take' | 'release';
485
+ }
486
+ export interface LockResponse {
487
+ state: LockState;
488
+ lock: LockView | null;
489
+ }
490
+
491
+ /** POST /_caracms/publish. */
492
+ export interface PublishResponse {
493
+ result: 'published' | 'nothing-waiting' | 'joined';
494
+ publish: PublishView;
495
+ }
496
+
497
+ export interface HistoryEntryView {
498
+ commit: string;
499
+ by: Person | null;
500
+ at: string;
501
+ titles: string[];
502
+ files: string[];
503
+ state: 'publishing' | 'live' | 'failed';
504
+ }
505
+ /** GET /_caracms/history. */
506
+ export interface HistoryResponse {
507
+ timeZone: string;
508
+ entries: HistoryEntryView[];
509
+ }
510
+
511
+ /** POST /_caracms/try-again: 200 `{ ok: true }`, or 409/502 `{ ok: false, error }`. */
512
+ export type TryAgainResponse = { ok: true } | { ok: false; error: string };
513
+
514
+ /** POST /_caracms/notice `{ id }` → `{ ok: true }`. */
515
+ export interface NoticeRequest {
516
+ id: string;
517
+ }
518
+
519
+ // ── Creating and deleting (M5a Phase 3, Decisions 11 and 12) ──────────────────
520
+
521
+ /**
522
+ * POST /_caracms/create. `path` is the new document's path in its collection, its
523
+ * address parts made with slug.ts → `slugify` (`studio/visit`; a fields collection's or
524
+ * shared blocks' is one part). `title` is what the document is called. A shared block
525
+ * also names its block's type.
526
+ */
527
+ export interface CreateRequest {
528
+ collection: string;
529
+ path: string;
530
+ title: string;
531
+ /** A shared block's block type (required for `shared`, refused otherwise). */
532
+ blockType?: string;
533
+ }
534
+
535
+ /** 200: the document exists now (on disk; in live mode, as the creator's autosave, the lock theirs). */
536
+ export interface CreateResponse {
537
+ ok: true;
538
+ collection: string;
539
+ path: string;
540
+ /** Relative to the site, as everywhere (`content/pages/studio/visit.json`). */
541
+ file: string;
542
+ /** Its public address; null for a shared block. */
543
+ url: string | null;
544
+ /** fileVersion of its first text. */
545
+ version: string;
546
+ }
547
+
548
+ /** POST /_caracms/delete. */
549
+ export interface DeleteRequest {
550
+ collection: string;
551
+ path: string;
552
+ }
553
+
554
+ /** 200: the document is gone (from disk; in live mode, a "gone" autosave the sweep commits). */
555
+ export interface DeleteResponse {
556
+ ok: true;
557
+ collection: string;
558
+ path: string;
559
+ file: string;
560
+ }
561
+
562
+ /**
563
+ * POST /_caracms/move (pre-M7 item 8): change a document's address. `to` is its new
564
+ * path in the same collection (`studio/visit`), its new parts made with slug.ts as a
565
+ * new document's are. Every link and reference to it is rewritten, and a redirect from
566
+ * the old address to the new is added to Site Settings.
567
+ */
568
+ export interface MoveRequest {
569
+ collection: string;
570
+ path: string;
571
+ to: string;
572
+ }
573
+
574
+ /** 200: the document is at its new path (live mode: as autosaves, all going out at the next Publish). */
575
+ export interface MoveResponse {
576
+ ok: true;
577
+ collection: string;
578
+ /** The new path, file and address. */
579
+ path: string;
580
+ file: string;
581
+ url: string;
582
+ /** Where it was. */
583
+ from: { path: string; file: string; url: string };
584
+ /** The other documents whose links (or references) to it were rewritten, with their titles. */
585
+ relinked: { collection: string; path: string; title: string }[];
586
+ /** The redirect added: null for a document that was never live (made since the last Publish). */
587
+ redirect: { from: string; to: string } | null;
588
+ }
589
+
590
+ /**
591
+ * Why a create or delete was refused: `{ error, code }` with a status.
592
+ * - 400 `bad-request` (the body), `bad-path` (not a document path, outside its
593
+ * collection, a folder no collection declares, a link out), `bad-name` (a part that
594
+ * isn't an address `slugify` leaves as it is, or nothing left), `index`,
595
+ * `reserved-address` (under `/admin` or `/_caracms`), `below-collection` (strictly
596
+ * under another collection's address), `not-allowed` (Site Settings, Home, a shared
597
+ * block with no or an unknown block type);
598
+ * - 404 `not-found` (delete: no such document);
599
+ * - 409 `exists` (create: the document is already there), `address-taken` (another
600
+ * document has that address: "There's already a page at /services/packaging"),
601
+ * `in-use` (a shared block something still uses; `usedOn` says where), `locked`
602
+ * (live: someone else holds the document's lock: "Aoife is editing Contact"; the
603
+ * body is a LockRefusal's);
604
+ * - and the write route's: 401 `signed-out`, 403, 405, 413, 415, 503 `unavailable`, 400 `no-tab`.
605
+ */
606
+ export interface CreateDeleteRefusal extends ApiError {
607
+ code?: CreateDeleteCode | string;
608
+ /** `in-use`: the documents that use the shared block, with their titles. */
609
+ usedOn?: { collection: string; path: string; title: string }[];
610
+ }
611
+
612
+ export type CreateDeleteCode =
613
+ | 'bad-request'
614
+ | 'bad-path'
615
+ | 'bad-name'
616
+ | 'index'
617
+ | 'reserved-address'
618
+ | 'below-collection'
619
+ | 'not-allowed'
620
+ | 'not-found'
621
+ | 'exists'
622
+ | 'address-taken'
623
+ | 'in-use'
624
+ | 'locked'
625
+ | 'same';
626
+
627
+ // ── Media (M5b Phase 2: Decisions 6, 7 and 12) ────────────────────────────────
628
+
629
+ /**
630
+ * The media routes, under `/_caracms/media/`, in both modes (live mode: a session, and
631
+ * the same origin for a POST, by the guard's prefix rule; local mode: no sign-in). No
632
+ * lock: nothing here changes a document (Decision 7). Every reply is
633
+ * `Cache-Control: private, no-store`. Unconfigured: 503 `MediaUnset`, images and clips
634
+ * apart (one missing never disables the other).
635
+ *
636
+ * | Route | Takes | Gives |
637
+ * |---|---|---|
638
+ * | `POST upload` | the image's bytes as the body (any Content-Type), its file name in `X-Caracms-File-Name` (URI-encoded) | 201 `MediaUploadResponse` (new), 200 (the same bytes were already there: nothing written), or `MediaRefusal` 400/413/415/422 |
639
+ * | `GET images` | | `MediaImagesResponse`: the library, newest first |
640
+ * | `GET thumbnail?key=&width=` | an image's key, a ladder width (default 320) | 302 to the signed imgproxy URL (for a key the editor has from content) |
641
+ * | `POST video-upload` | `VideoUploadRequest` (JSON) | 201 `VideoUploadResponse`: the browser PUTs the file to `url` (UpChunk) |
642
+ * | `GET video-upload/<id>` | | `VideoUploadStatus` |
643
+ * | `GET clips` | | `MediaClipsResponse`: the whole Mux library, newest first |
644
+ * | `GET clips/<assetId>` | | `LibraryClip` |
645
+ * | `GET playback/<playbackId>` | | `LibraryClip` (another environment's ID, or an unknown one: 404) |
646
+ */
647
+ export const MEDIA_ENDPOINTS = {
648
+ upload: '/_caracms/media/upload',
649
+ images: '/_caracms/media/images',
650
+ thumbnail: '/_caracms/media/thumbnail',
651
+ videoUpload: '/_caracms/media/video-upload',
652
+ clips: '/_caracms/media/clips',
653
+ playback: '/_caracms/media/playback',
654
+ /** M6c G4: Replace… on a file (`ReplaceFileRequest` → `ReplaceFileResponse`). */
655
+ replace: '/_caracms/media/replace',
656
+ } as const;
657
+
658
+ /** The upload's file name, `encodeURIComponent`-ed (a header takes no other characters). */
659
+ export const MEDIA_NAME_HEADER = 'X-Caracms-File-Name';
660
+
661
+ /** Decision 6: the largest original the library takes, in bytes (40 MB, as Finder counts them). */
662
+ export const MEDIA_MAX_BYTES = 40_000_000;
663
+ /** Decision 6: the most pixels (imgproxy's `IMGPROXY_MAX_SRC_RESOLUTION` 50, in megapixels). */
664
+ export const MEDIA_MAX_PIXELS = 50_000_000;
665
+ /** The types the library takes, as every refusal names them. */
666
+ export const MEDIA_TYPES_TEXT = 'JPEG, PNG, WebP, AVIF, HEIC, GIF or SVG';
667
+ /** M6c G4: the files the library takes (caracms/files' table, by label), as every refusal names them. */
668
+ export const FILE_TYPES_TEXT = 'PDF, Word, Excel, PowerPoint, CSV or Zip';
669
+ /** Decision 6: a clip dropped on an image-only slot (the editor says it; the server never sees the clip). */
670
+ export const VIDEO_ON_IMAGE_SLOT_MESSAGE = "That's a video, and this slot takes images only.";
671
+
672
+ /** A document that uses an image or a clip (the store's listing: `drafts` with autosaves in live mode, the disk in local mode). */
673
+ export interface MediaUse {
674
+ collection: string;
675
+ path: string;
676
+ /** The document's label in the listing. */
677
+ title: string;
678
+ }
679
+
680
+ /** One image in the library (Decision 7): an object under `uploads/` in the bucket, with its metadata. */
681
+ export interface LibraryImage {
682
+ /** M6c G4: the listing's entries say what they are (a file is `LibraryFile`). */
683
+ kind: 'image';
684
+ /** The bucket key, what a slot stores: `uploads/3f9a1c07b2e4/harbour-wall.jpg`. */
685
+ key: string;
686
+ /** The original file name (metadata `name`), or the key's own file name when it has none. */
687
+ name: string;
688
+ /** The pixel size after EXIF orientation (metadata `w`/`h`); null when the object has none. */
689
+ w: number | null;
690
+ h: number | null;
691
+ /** The original's size in bytes, from the listing; null when unknown. */
692
+ bytes: number | null;
693
+ /** ISO: when it was uploaded (the object's Last-Modified). The library is sorted on it, newest first. */
694
+ uploadedAt: string | null;
695
+ /** Who uploaded it (metadata `by`: an address, or `setup`); null in local mode, which has no sign-in. Show nothing then. */
696
+ by: string | null;
697
+ /**
698
+ * A signed imgproxy URL, `rs:fit:320:0` WebP (a GIF or an SVG passes through as itself).
699
+ * Always this, never the original: Chrome can't show a HEIC. The signing key stays on the server.
700
+ */
701
+ thumbnail: string;
702
+ /** The same at 640 wide, for a 2× screen: `srcset="<thumbnail> 1x, <thumbnail2x> 2x"`. */
703
+ thumbnail2x: string;
704
+ /** Every document that uses it, in listing order (empty: unused, for the toggle). */
705
+ usedIn: MediaUse[];
706
+ /** An alt text already used for it (the first non-empty in the listing's document order, decorative ones skipped), or null. Picking fills the slot's alt with it. */
707
+ alt: string | null;
708
+ /** A focal point already set for it (the first in the listing's document order, decorative uses included), or null. Picking brings it along with the alt. */
709
+ focal: [number, number] | null;
710
+ }
711
+
712
+ /** GET /_caracms/media/images. No paging in v1 (Decision 7). M6c G4: the files beside the images, one listing. */
713
+ export interface MediaImagesResponse {
714
+ images: LibraryImage[];
715
+ files: LibraryFile[];
716
+ }
717
+
718
+ /** POST /_caracms/media/upload: the image's library entry, ready to place (a file's is `MediaFileUploadResponse`). */
719
+ export interface MediaUploadResponse {
720
+ image: LibraryImage;
721
+ /** False: the same bytes were already in the library (under any name), so nothing was written and this is that image. */
722
+ created: boolean;
723
+ }
724
+
725
+ // ── M6c G4: files in the library (notes/2026-10-17-m6c-downloads-grid.md → Contracts) ──
726
+
727
+ /** Where a link to a file is: the document, and how many of its links point at the file. */
728
+ export interface FileUse extends MediaUse {
729
+ links: number;
730
+ }
731
+
732
+ /**
733
+ * One file in the library (Decision 8): an object under `uploads/` whose key ends in one of
734
+ * `caracms/files`' types. No size in pixels, no thumbnail, alt or focal point. Its address
735
+ * is `fileAddress(key)`.
736
+ */
737
+ export interface LibraryFile {
738
+ kind: 'file';
739
+ key: string;
740
+ /** The original file name (metadata `name`), or the key's own. */
741
+ name: string;
742
+ /** The type's label from the table: `PDF`, `Word`, `Excel`, `PowerPoint`, `CSV`, `Zip`. */
743
+ type: string;
744
+ bytes: number | null;
745
+ uploadedAt: string | null;
746
+ by: string | null;
747
+ /** Every document with a link to it (a link field, a button, rich text: found by schema), in listing order. */
748
+ usedIn: FileUse[];
749
+ }
750
+
751
+ /** M6c G4: POST /_caracms/media/upload of a file: its library entry (`created` as an image's). */
752
+ export interface MediaFileUploadResponse {
753
+ file: LibraryFile;
754
+ created: boolean;
755
+ }
756
+
757
+ /**
758
+ * POST /_caracms/media/replace (Decision 9): every link to `from` (a file's key) moves to
759
+ * `to` (a file's key, uploaded first through `upload`), `?download` and any fragment kept,
760
+ * in every document the store lists, Site Settings and shared blocks included. Live mode:
761
+ * lands whole or rolls back, each rewritten document a waiting change. Local mode: written
762
+ * as a local move writes.
763
+ */
764
+ export interface ReplaceFileRequest {
765
+ from: string;
766
+ to: string;
767
+ }
768
+
769
+ export interface ReplaceFileResponse {
770
+ ok: true;
771
+ from: string;
772
+ to: string;
773
+ /** The documents whose links were rewritten, with how many links each (empty: nothing linked to it). */
774
+ relinked: FileUse[];
775
+ }
776
+
777
+ /** Why an upload was refused: the sentence the editor shows (Decision 6), and a code. */
778
+ export interface MediaRefusal extends ApiError {
779
+ code: 'not-an-image' | 'unknown-type' | 'too-large' | 'too-many-pixels' | 'no-size' | 'empty' | 'macro-enabled' | 'not-a-csv' | 'unnamed-office' | string;
780
+ }
781
+
782
+ /** 503 from a media route that isn't set up: the sentence names what's missing (Decision 5). */
783
+ export interface MediaUnset extends ApiError {
784
+ /** `media-unset`: the image routes (the bucket and imgproxy); `mux-unset`: the clip routes. */
785
+ code: 'media-unset' | 'mux-unset';
786
+ /** The variables that aren't set (empty when something else is wrong, such as memory mode on a production server). */
787
+ missing: string[];
788
+ }
789
+
790
+ /** Whether the image routes and the clip routes are set up: null when they are, else the 503's body (its sentence). */
791
+ export interface MediaSetup {
792
+ images: MediaUnset | null;
793
+ clips: MediaUnset | null;
794
+ }
795
+
796
+ /** POST /_caracms/media/video-upload: the file's name becomes the clip's title. */
797
+ export interface VideoUploadRequest {
798
+ name: string;
799
+ }
800
+
801
+ /** 201: a Mux direct upload for this site's origin. The browser PUTs the file to `url`, then polls `video-upload/<id>`. */
802
+ export interface VideoUploadResponse {
803
+ id: string;
804
+ url: string;
805
+ }
806
+
807
+ /** GET /_caracms/media/video-upload/<id> (Mux's upload, as the template's endpoint gives it). */
808
+ export interface VideoUploadStatus {
809
+ id: string;
810
+ /** `waiting` (no file yet), `asset_created` (`assetId` set: poll `clips/<assetId>`), `errored`, `cancelled`, `timed_out`. */
811
+ status: string;
812
+ assetId: string | null;
813
+ /** Mux's short reason when errored, else null. */
814
+ error: string | null;
815
+ }
816
+
817
+ /** One clip from the site's Mux environment (the template's `Clip`, plus its size and where it's used). */
818
+ export interface LibraryClip {
819
+ /** The Mux asset id. */
820
+ id: string;
821
+ /** Its first public playback ID: what a slot stores as `video`. Null only on an errored clip. */
822
+ playbackId: string | null;
823
+ /** Mux's asset status: `preparing`, `ready` or `errored`. */
824
+ status: string;
825
+ /** **Placeable**: the asset is ready AND its `highest` MP4 is ready (Decision 12). Only then may a slot store it. */
826
+ ready: boolean;
827
+ /** It can never become ready: Mux's `errored`, or a ready asset whose `highest` MP4 is missing, errored or skipped. */
828
+ errored: boolean;
829
+ /** When errored: a sentence a designer can act on (Mux's short reason, or what to do about the MP4). */
830
+ error: string | null;
831
+ /** The clip's title (the uploaded file's name). */
832
+ title: string | null;
833
+ /** Seconds. */
834
+ duration: number | null;
835
+ /** `16:9`. */
836
+ aspectRatio: string | null;
837
+ /** The video track's size, for the slot's `w`/`h`. */
838
+ w: number | null;
839
+ h: number | null;
840
+ /** ISO. */
841
+ createdAt: string | null;
842
+ /** Every document whose slot holds its playback ID, in listing order. */
843
+ usedIn: MediaUse[];
844
+ }
845
+
846
+ /** GET /_caracms/media/clips: the whole library, following Mux's cursor, newest first. Errored clips always show. */
847
+ export interface MediaClipsResponse {
848
+ clips: LibraryClip[];
849
+ }
850
+
851
+ // ── M6c (G5): the grid view ──────────────────────────────────────────────────
852
+ // Kept in one block (G4 adds to this file beside it): the fields G5 adds to the shared
853
+ // interfaces above are declared here, merged into them.
854
+
855
+ export type { ResolvedView, ViewMode, CardRatio } from '../cards.ts';
856
+
857
+ export interface EditorAnyCollection {
858
+ /** The List / Grid switch's view (cards.ts); null or absent when the collection gets no switch (Decision 13). */
859
+ view?: import('../cards.ts').ResolvedView | null;
860
+ }
861
+
862
+ export interface ListedDocument {
863
+ /** The document's card picture, signed (editor/server/cards.ts); absent for a text card, or when the media variables aren't set. */
864
+ card?: CardPicture;
865
+ }
866
+
867
+ export interface UiState {
868
+ /** Each collection's List / Grid choice, by collection name (Decision 18). Absent: the config's default. */
869
+ views?: Record<string, import('../cards.ts').ViewMode>;
870
+ }
871
+
872
+ /**
873
+ * A grid card's picture as the listing carries it (Decision 19): already cropped to the
874
+ * collection's ratio by the server, at one width rung (`src`) and its 2× (`src2x`), so
875
+ * the grid draws without opening a document or asking the thumbnail route.
876
+ * `position` is the focal point as `object-position`, for a GIF or an SVG, which pass
877
+ * through uncropped. A clip's is its Mux poster, cropped the same, with its duration.
878
+ */
879
+ export type CardPicture =
880
+ | { kind: 'image'; src: string; src2x: string; alt: string; position: string | null }
881
+ | { kind: 'clip'; src: string; src2x: string; alt: string; duration: number | null };