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,618 @@
1
+ /**
2
+ * The media library in the browser (M5b Phase 3; Decisions 6, 7 and 12): one per editor,
3
+ * `mediaFor(editor)`, made on first use. What the media field, the library level and the
4
+ * stage's drop target read and call.
5
+ *
6
+ * const media = mediaFor(editor);
7
+ * media.images / media.clips the library, newest first: last-known while a refetch runs (never a skeleton)
8
+ * media.files M6c G4: the library's files (PDF, Office, CSV, zip), from the images listing
9
+ * media.loadImages() / loadClips() read it again (the server keeps its listing 60 s)
10
+ * media.upload(files, slot?) images to the bucket (through the site's server, with real progress),
11
+ * clips straight to Mux (UpChunk), then polled until they can play
12
+ * media.place(slot, entry) Decision 3's value through `pickMedia`, written with `doc.setField`
13
+ * media.showUnused the unused toggle, remembered per browser (localStorage)
14
+ * media.placed the last placement, for Phase 4's ask for alt text
15
+ * media.refused the library's refused files, kept while the library is open
16
+ *
17
+ * - **Signing never reaches the browser**: every image URL here is one the server signed
18
+ * (the listing's `thumbnail`, or the thumbnail route's redirect, `thumbnailSrc`).
19
+ * - **Nothing is ever deleted**, here or on the server. Remove in a slot only clears it.
20
+ * - **Several files dropped on one slot**: the first the slot takes is placed, the rest go
21
+ * to the library. A clip on an image-only slot, alone, gets Decision 6's sentence.
22
+ * - **A clip is placeable only when it can play** (`LibraryClip.ready`: the asset and its
23
+ * `highest` MP4).
24
+ * - Uploads, the library and the toggle need no lock; placing is an edit (the document
25
+ * store refuses it when the document is read-only).
26
+ */
27
+ import { getAtPath } from '../../../fields.ts';
28
+ import { fileTypeOf } from '../../../files.ts';
29
+ import { pickMedia, type MediaOnly, type MediaValue } from '../../../media-value.ts';
30
+ import {
31
+ MEDIA_ENDPOINTS,
32
+ MEDIA_NAME_HEADER,
33
+ VIDEO_ON_IMAGE_SLOT_MESSAGE,
34
+ type LibraryClip,
35
+ type LibraryFile,
36
+ type LibraryImage,
37
+ type MediaClipsResponse,
38
+ type MediaFileUploadResponse,
39
+ type MediaImagesResponse,
40
+ type MediaUnset,
41
+ type MediaUploadResponse,
42
+ type VideoUploadResponse,
43
+ type VideoUploadStatus,
44
+ } from '../../types.ts';
45
+ import type { Editor } from '../editor.ts';
46
+ import { createStore, type Store } from '../state/store.ts';
47
+
48
+ /** A slot: the host's scope (a block's key, or a form's) and a dotted path in it. */
49
+ export interface SlotRef {
50
+ scope: string;
51
+ path: string;
52
+ }
53
+
54
+ /** A slot and what it takes. */
55
+ export interface SlotTarget extends SlotRef {
56
+ only?: MediaOnly;
57
+ play?: 'loop';
58
+ /** A list's Add image: one of several picked in a row, each asked for its alt text in turn. */
59
+ multi?: boolean;
60
+ }
61
+
62
+ export const slotKey = (slot: SlotRef): string => `${slot.scope}\u0000${slot.path}`;
63
+
64
+ export interface Listing<T> {
65
+ /** Null until the first answer. */
66
+ list: T[] | null;
67
+ /** The route answered 503: its sentence (Decision 5). */
68
+ unset: MediaUnset | null;
69
+ /** The last read failed (the list stays as it was). */
70
+ error: string | null;
71
+ }
72
+
73
+ export interface MediaUpload {
74
+ id: string;
75
+ name: string;
76
+ /** `file`: one of caracms/files' types by its name (M6c G4); the server reads the bytes either way. */
77
+ kind: 'image' | 'clip' | 'file';
78
+ /** Its size in bytes, for a file row's "8.2 MB". */
79
+ bytes: number;
80
+ /** 0–100: real progress (bytes sent), never a skeleton. */
81
+ pct: number;
82
+ /** `up` sending, `proc` Mux getting it ready to play, `failed`. */
83
+ phase: 'up' | 'proc' | 'failed';
84
+ /** An object URL of the file itself when the browser can show it (never a HEIC), else null. */
85
+ preview: string | null;
86
+ /** Why it failed, a sentence to act on. */
87
+ error: string | null;
88
+ /** The slot it goes into when done (slotKey), or null for the library. */
89
+ slot: string | null;
90
+ }
91
+
92
+ export interface Refusal {
93
+ id: string;
94
+ name: string;
95
+ message: string;
96
+ }
97
+
98
+ /** The last placement: Phase 4 asks for alt text on it (focus the slot's alt input). */
99
+ export interface Placed {
100
+ scope: string;
101
+ path: string;
102
+ kind: 'image' | 'clip';
103
+ /** The alt text it came with (an alt already used for it), or "". */
104
+ alt: string;
105
+ /** Where that alt came from (a document's title), when it did. */
106
+ altFrom: string | null;
107
+ /** Rises with each placement, so the same slot placed twice is seen twice. */
108
+ n: number;
109
+ /** One of several picked in a row from a list's Add image (the gallery). */
110
+ multi: boolean;
111
+ }
112
+
113
+ export interface MediaLibrary {
114
+ images: Store<Listing<LibraryImage>>;
115
+ clips: Store<Listing<LibraryClip>>;
116
+ /** M6c G4: the library's files, read with the images (one listing). */
117
+ files: Store<Listing<LibraryFile>>;
118
+ uploads: Store<MediaUpload[]>;
119
+ /** Files the library refused (a library drop or Upload…), each with its sentence. */
120
+ refused: Store<Refusal[]>;
121
+ /** A refusal at a slot (a clip on an image-only slot, a PDF), by slotKey. */
122
+ slotNotes: Store<ReadonlyMap<string, string>>;
123
+ showUnused: Store<boolean>;
124
+ /** Keys (and `clip:<asset id>`) uploaded in this tab: always shown, marked "Just added" (a file "Just uploaded"). */
125
+ fresh: Store<ReadonlySet<string>>;
126
+ placed: Store<Placed | null>;
127
+ loadImages(): Promise<void>;
128
+ loadClips(): Promise<void>;
129
+ /** Upload files: into `slot` (the first it takes), the rest into the library. */
130
+ upload(files: readonly File[], slot?: SlotTarget | null): void;
131
+ /**
132
+ * M6c G4: upload one file for a link's picker or Replace…: real progress in `uploads`
133
+ * (its row keyed by `tag`), then the file's entry (`created` false: those bytes were
134
+ * already in the library), or a refusal's sentence.
135
+ */
136
+ uploadFile(file: File, tag: string): Promise<{ ok: true; file: LibraryFile; created: boolean } | { ok: false; error: string }>;
137
+ /** Put a library entry in a slot (Decision 3's value). False when it can't be placed (a clip not ready, a kind the slot doesn't take, read-only). */
138
+ place(slot: SlotTarget, entry: LibraryImage | LibraryClip): boolean;
139
+ setShowUnused(on: boolean): void;
140
+ dismissRefused(id: string): void;
141
+ /** Stop an upload (Cancel) or forget a failed one (Dismiss). */
142
+ dismissUpload(id: string): void;
143
+ clearSlotNote(slot: SlotRef): void;
144
+ }
145
+
146
+ const made = new WeakMap<Editor, MediaLibrary>();
147
+
148
+ /** The thumbnail of an image a slot holds, through the server's signing redirect (a ladder width). */
149
+ export const thumbnailSrc = (key: string, width = 320): string => `${MEDIA_ENDPOINTS.thumbnail}?key=${encodeURIComponent(key)}&width=${width}`;
150
+
151
+ /** A clip's still at `time` seconds, small (Mux's thumbnail host; no signing). */
152
+ export const clipStill = (playbackId: string, time?: number, width = 320): string =>
153
+ `https://image.mux.com/${encodeURIComponent(playbackId)}/thumbnail.webp?width=${width}&time=${typeof time === 'number' && Number.isFinite(time) && time >= 0 ? time : 0}`;
154
+
155
+ export const clipId = (clip: Pick<LibraryClip, 'id'>): string => `clip:${clip.id}`;
156
+
157
+ /** What a dropped or chosen file is, by its type and name (the server checks the bytes). */
158
+ export function fileKind(file: Pick<File, 'name' | 'type'>): 'image' | 'clip' | 'file' | null {
159
+ const ext = (/\.(\w+)$/.exec(file.name.toLowerCase()) ?? [, ''])[1];
160
+ const type = file.type || '';
161
+ if (/^(jpe?g|png|webp|avif|heic|heif|gif|svg)$/.test(ext!) || /^image\//.test(type)) return 'image';
162
+ if (/^(mov|mp4|m4v|webm|mkv|avi)$/.test(ext!) || type.startsWith('video/')) return 'clip';
163
+ if (fileTypeOf(file.name)) return 'file';
164
+ return null;
165
+ }
166
+
167
+ /** "1.8 MB", "64 KB": a file's size as the library's rows show it (the 40 MB cap counts 1,000,000 bytes to the MB). */
168
+ export function fileSize(bytes: number | null): string {
169
+ if (bytes === null || !Number.isFinite(bytes)) return '';
170
+ if (bytes < 1000) return `${bytes} bytes`;
171
+ if (bytes < 1_000_000) return `${Math.max(1, Math.round(bytes / 1000))} KB`;
172
+ return `${(bytes / 1_000_000).toFixed(1).replace(/\.0$/, '')} MB`;
173
+ }
174
+
175
+ /** A slot's note for a file dropped on it: it went into the library under Files (a slot never takes a file). */
176
+ export const FILE_ON_SLOT = "This slot doesn't take files.";
177
+
178
+ /** Can the browser show this file as it is (for the upload's own preview)? Never a HEIC; never an SVG inlined. */
179
+ const showable = (file: File) => /^image\/(jpeg|png|webp|gif|avif)$/.test(file.type || '');
180
+
181
+ /** The body's Content-Type: the file's own when it's an image, else octet-stream (Astro's origin check answers a form type first). */
182
+ const bodyType = (file: File) => (/^image\//.test(file.type) ? file.type : 'application/octet-stream');
183
+
184
+ const UPLOAD_POLL_MS = 2000;
185
+ const ASSET_POLL_MS = 3000;
186
+ const LIBRARY_POLL_MS = 3000;
187
+ const UNUSED_KEY = (site: string) => `caracms:media-show-unused:${site}`;
188
+
189
+ const FAILED_REACH = "The upload didn't reach the server, so nothing was stored. Try again.";
190
+
191
+ export function mediaFor(editor: Editor): MediaLibrary {
192
+ const existing = made.get(editor);
193
+ if (existing) return existing;
194
+
195
+ const images = createStore<Listing<LibraryImage>>({ list: null, unset: editor.mediaSetup.get().images, error: null });
196
+ const clips = createStore<Listing<LibraryClip>>({ list: null, unset: editor.mediaSetup.get().clips, error: null });
197
+ const files = createStore<Listing<LibraryFile>>({ list: null, unset: editor.mediaSetup.get().images, error: null });
198
+ const uploads = createStore<MediaUpload[]>([]);
199
+ const refused = createStore<Refusal[]>([]);
200
+ const slotNotes = createStore<ReadonlyMap<string, string>>(new Map());
201
+ const fresh = createStore<ReadonlySet<string>>(new Set());
202
+ const placed = createStore<Placed | null>(null);
203
+ const showUnused = createStore<boolean>(readShowUnused(editor.site.storageKey));
204
+ const cancels = new Map<string, () => void>();
205
+ let n = 0;
206
+ let placedN = 0;
207
+ const nextId = () => `up-${++n}`;
208
+
209
+ const setUnset = (side: 'images' | 'clips', unset: MediaUnset) => {
210
+ editor.mediaSetup.update({ [side]: unset });
211
+ (side === 'images' ? images : clips).update({ unset } as never);
212
+ // Files share the bucket with images: one unset, both.
213
+ if (side === 'images') files.update({ unset });
214
+ };
215
+
216
+ let imagesInFlight: Promise<void> | null = null;
217
+ async function loadImages(): Promise<void> {
218
+ if (imagesInFlight) return imagesInFlight;
219
+ imagesInFlight = (async () => {
220
+ try {
221
+ const response = await fetch(MEDIA_ENDPOINTS.images, { credentials: 'same-origin' });
222
+ const body = (await response.json().catch(() => null)) as MediaImagesResponse | MediaUnset | null;
223
+ if (response.status === 503 && body && 'code' in body) return setUnset('images', body as MediaUnset);
224
+ if (!response.ok || !body || !('images' in body)) {
225
+ const error = (body as { error?: string } | null)?.error ?? `The library didn't answer (${response.status}).`;
226
+ images.update({ error });
227
+ files.update({ error });
228
+ return;
229
+ }
230
+ images.set({ list: body.images, unset: null, error: null });
231
+ files.set({ list: body.files ?? [], unset: null, error: null });
232
+ } catch {
233
+ images.update({ error: "Can't reach the server for the library." });
234
+ files.update({ error: "Can't reach the server for the library." });
235
+ } finally {
236
+ imagesInFlight = null;
237
+ }
238
+ })();
239
+ return imagesInFlight;
240
+ }
241
+
242
+ let clipsInFlight: Promise<void> | null = null;
243
+ async function loadClips(): Promise<void> {
244
+ if (clipsInFlight) return clipsInFlight;
245
+ clipsInFlight = (async () => {
246
+ try {
247
+ const response = await fetch(MEDIA_ENDPOINTS.clips, { credentials: 'same-origin' });
248
+ const body = (await response.json().catch(() => null)) as MediaClipsResponse | MediaUnset | null;
249
+ if (response.status === 503 && body && 'code' in body) return setUnset('clips', body as MediaUnset);
250
+ if (!response.ok || !body || !('clips' in body)) {
251
+ clips.update({ error: (body as { error?: string } | null)?.error ?? `Mux didn't answer (${response.status}).` });
252
+ return;
253
+ }
254
+ clips.set({ list: body.clips, unset: null, error: null });
255
+ } catch {
256
+ clips.update({ error: "Can't reach the server for the clips." });
257
+ } finally {
258
+ clipsInFlight = null;
259
+ scheduleClipPoll();
260
+ }
261
+ })();
262
+ return clipsInFlight;
263
+ }
264
+
265
+ // While the library (or a picker) is open and a clip is still processing at Mux, ask again (the template's poll).
266
+ let clipPoll: ReturnType<typeof setTimeout> | null = null;
267
+ function scheduleClipPoll() {
268
+ if (clipPoll || typeof window === 'undefined') return;
269
+ const processing = (clips.get().list ?? []).some((c) => !c.ready && !c.errored);
270
+ if (!processing || editor.view.get().level !== 'media') return;
271
+ clipPoll = setTimeout(() => {
272
+ clipPoll = null;
273
+ if (editor.view.get().level === 'media') void loadClips();
274
+ }, LIBRARY_POLL_MS);
275
+ }
276
+ editor.view.subscribe((view, before) => {
277
+ if (view.level === 'media' && before.level !== 'media') scheduleClipPoll();
278
+ // A refused file is said where it was dropped: gone once the library is left or a slot's picker opens.
279
+ const left = before.level === 'media' && view.level !== 'media';
280
+ const picking = !!view.media?.pick && !before.media?.pick;
281
+ if ((left || picking) && refused.get().length) refused.set([]);
282
+ });
283
+
284
+ // M6c G4 (Decision 10): the first time an opened document holds a /files/ link, the library
285
+ // is read once (the server answers from its cached listing), so its file keys can say which
286
+ // links point at a file that isn't there. Not on every load: most documents link to none.
287
+ let askedForFiles = false;
288
+ const watchFiles = (base: string) => {
289
+ if (askedForFiles || files.get().list || !base.includes('/files/')) return;
290
+ askedForFiles = true;
291
+ void loadImages();
292
+ };
293
+ watchFiles(editor.doc.get().base ?? '');
294
+ editor.doc.subscribe((session) => watchFiles(session.base ?? ''));
295
+
296
+ const patchUpload = (id: string, patch: Partial<MediaUpload>) => uploads.set((list) => list.map((u) => (u.id === id ? { ...u, ...patch } : u)));
297
+ const dropUpload = (id: string) => {
298
+ const u = uploads.get().find((x) => x.id === id);
299
+ if (u?.preview) URL.revokeObjectURL(u.preview);
300
+ cancels.delete(id);
301
+ uploads.set((list) => list.filter((x) => x.id !== id));
302
+ };
303
+ const refuse = (name: string, message: string) => refused.set((list) => [{ id: nextId(), name, message }, ...list]);
304
+ const noteSlot = (slot: SlotRef, message: string | null) =>
305
+ slotNotes.set((map) => {
306
+ const next = new Map(map);
307
+ if (message) next.set(slotKey(slot), message);
308
+ else next.delete(slotKey(slot));
309
+ return next;
310
+ });
311
+ const markFresh = (id: string) => fresh.set((set) => new Set([...set, id]));
312
+
313
+ function place(slot: SlotTarget, entry: LibraryImage | LibraryClip): boolean {
314
+ if (editor.access.get().readOnly) return false;
315
+ const block = editor.doc.block(slot.scope);
316
+ if (!block) return false;
317
+ const previous = getAtPath(block.data, slot.path);
318
+ let value: MediaValue;
319
+ let alt = '';
320
+ let altFrom: string | null = null;
321
+ if ('key' in entry) {
322
+ if (slot.only === 'video') return false;
323
+ alt = entry.alt ?? '';
324
+ altFrom = entry.alt ? (entry.usedIn[0]?.title ?? null) : null;
325
+ value = pickMedia(previous, { key: entry.key, w: entry.w ?? undefined, h: entry.h ?? undefined, alt, focal: entry.focal });
326
+ } else {
327
+ if (slot.only === 'image' || !entry.ready || !entry.playbackId) return false;
328
+ value = pickMedia(previous, { video: entry.playbackId, duration: entry.duration ?? undefined, w: entry.w ?? undefined, h: entry.h ?? undefined });
329
+ // A loop-only slot (the hero) stores "play": "loop" (Decision 3).
330
+ if (slot.play === 'loop' && 'video' in value) value = { ...value, play: 'loop' };
331
+ }
332
+ editor.doc.setField(slot.scope, slot.path, value);
333
+ noteSlot(slot, null);
334
+ placed.set({ scope: slot.scope, path: slot.path, kind: 'key' in entry ? 'image' : 'clip', alt, altFrom, n: ++placedN, multi: !!slot.multi });
335
+ editor.announce(`${'key' in entry ? entry.name : (entry.title ?? 'The clip')} placed`);
336
+ return true;
337
+ }
338
+
339
+ function addImage(image: LibraryImage) {
340
+ const list = images.get().list ?? [];
341
+ images.update({ list: [image, ...list.filter((x) => x.key !== image.key)] });
342
+ markFresh(image.key);
343
+ }
344
+
345
+ function addFile(entry: LibraryFile) {
346
+ const list = files.get().list ?? [];
347
+ files.update({ list: [entry, ...list.filter((x) => x.key !== entry.key)] });
348
+ markFresh(entry.key);
349
+ }
350
+
351
+ type Sent = { ok: true; image: LibraryImage } | { ok: true; file: LibraryFile; created: boolean } | { ok: false; error: string; aborted?: boolean };
352
+
353
+ /**
354
+ * Send one file's bytes to the upload route, its row in `uploads` showing real progress
355
+ * (`id`, gone once it answers). The answer: the image or the file the server made of it, or why not.
356
+ */
357
+ function send(file: File, id: string, slot: SlotTarget | null): Promise<Sent> {
358
+ const kind = fileKind(file) === 'file' ? 'file' : 'image';
359
+ uploads.set((list) => [{ id, name: file.name, kind, bytes: file.size, pct: 0, phase: 'up', preview: kind === 'image' && showable(file) ? URL.createObjectURL(file) : null, error: null, slot: slot ? slotKey(slot) : null }, ...list]);
360
+ return new Promise<Sent>((resolve) => {
361
+ const xhr = new XMLHttpRequest();
362
+ cancels.set(id, () => xhr.abort());
363
+ xhr.open('POST', MEDIA_ENDPOINTS.upload);
364
+ xhr.setRequestHeader('Content-Type', bodyType(file));
365
+ xhr.setRequestHeader(MEDIA_NAME_HEADER, encodeURIComponent(file.name));
366
+ xhr.upload.onprogress = (event) => {
367
+ if (event.lengthComputable) patchUpload(id, { pct: Math.min(99, Math.round((event.loaded / event.total) * 100)) });
368
+ };
369
+ const done = (sent: Sent) => {
370
+ dropUpload(id);
371
+ resolve(sent);
372
+ };
373
+ xhr.onerror = () => done({ ok: false, error: FAILED_REACH });
374
+ xhr.onabort = () => done({ ok: false, error: '', aborted: true });
375
+ xhr.onload = () => {
376
+ let body: unknown = null;
377
+ try {
378
+ body = JSON.parse(xhr.responseText);
379
+ } catch {
380
+ /* below */
381
+ }
382
+ if (xhr.status === 503 && body && typeof body === 'object' && 'code' in body) {
383
+ setUnset('images', body as MediaUnset);
384
+ return done({ ok: false, error: (body as MediaUnset).error });
385
+ }
386
+ if ((xhr.status === 200 || xhr.status === 201) && body && typeof body === 'object') {
387
+ if ('image' in body) return done({ ok: true, image: (body as MediaUploadResponse).image });
388
+ if ('file' in body) {
389
+ const answer = body as MediaFileUploadResponse;
390
+ return done({ ok: true, file: answer.file, created: answer.created });
391
+ }
392
+ }
393
+ const error = body && typeof body === 'object' && typeof (body as { error?: unknown }).error === 'string' ? (body as { error: string }).error : null;
394
+ done({ ok: false, error: xhr.status === 401 ? 'You were signed out, so nothing was stored. Sign in again, then upload it.' : (error ?? `The server didn't take it (${xhr.status}).`) });
395
+ };
396
+ xhr.send(file);
397
+ });
398
+ }
399
+
400
+ function startImage(file: File, slot: SlotTarget | null) {
401
+ void send(file, nextId(), slot).then((sent) => {
402
+ if (!sent.ok) {
403
+ if (sent.aborted) return;
404
+ if (slot) noteSlot(slot, sent.error);
405
+ else refuse(file.name, sent.error);
406
+ return;
407
+ }
408
+ if ('image' in sent) {
409
+ addImage(sent.image);
410
+ if (slot) place(slot, sent.image);
411
+ return;
412
+ }
413
+ // M6c G4: a file. Into the library under Files; a slot says where it went (a slot never takes a file).
414
+ addFile(sent.file);
415
+ if (slot) noteSlot(slot, FILE_ON_SLOT);
416
+ else if (editor.view.get().level === 'media' && !editor.view.get().media?.pick) editor.view.update({ media: { ...(editor.view.get().media ?? { pick: null, item: null, tab: 'files' }), tab: 'files' } });
417
+ });
418
+ }
419
+
420
+ async function getJson<T>(url: string, init?: RequestInit): Promise<{ status: number; body: T | null }> {
421
+ const response = await fetch(url, { credentials: 'same-origin', ...init });
422
+ const body = (await response.json().catch(() => null)) as T | null;
423
+ return { status: response.status, body };
424
+ }
425
+
426
+ const wait = (ms: number, signal: AbortSignal) =>
427
+ new Promise<void>((resolve, reject) => {
428
+ if (signal.aborted) return reject(new Error('aborted'));
429
+ const timer = setTimeout(resolve, ms);
430
+ signal.addEventListener('abort', () => (clearTimeout(timer), reject(new Error('aborted'))), { once: true });
431
+ });
432
+
433
+ function startClip(file: File, slot: SlotTarget | null) {
434
+ const id = nextId();
435
+ const controller = new AbortController();
436
+ const { signal } = controller;
437
+ let abortUpload: (() => void) | null = null;
438
+ cancels.set(id, () => {
439
+ controller.abort();
440
+ abortUpload?.();
441
+ });
442
+ uploads.set((list) => [{ id, name: file.name, kind: 'clip', bytes: file.size, pct: 0, phase: 'up', preview: null, error: null, slot: slot ? slotKey(slot) : null }, ...list]);
443
+ const failed = (error: string) => patchUpload(id, { phase: 'failed', error });
444
+
445
+ void (async () => {
446
+ // 1. A Mux direct upload for this site's origin; the token stays on the server.
447
+ let created: { status: number; body: (VideoUploadResponse & Partial<MediaUnset>) | null };
448
+ try {
449
+ created = await getJson<VideoUploadResponse & Partial<MediaUnset>>(MEDIA_ENDPOINTS.videoUpload, {
450
+ method: 'POST',
451
+ headers: { 'Content-Type': 'application/json' },
452
+ body: JSON.stringify({ name: file.name }),
453
+ signal,
454
+ });
455
+ } catch {
456
+ if (!signal.aborted) failed(FAILED_REACH);
457
+ return;
458
+ }
459
+ if (signal.aborted) return;
460
+ if (created.status === 503 && created.body?.code) {
461
+ setUnset('clips', created.body as MediaUnset);
462
+ failed(created.body.error ?? "Clips aren't set up here.");
463
+ return;
464
+ }
465
+ if (created.status !== 201 || !created.body?.url) {
466
+ failed(created.body?.error ?? `The server couldn't start the upload (${created.status}).`);
467
+ return;
468
+ }
469
+ const { id: uploadId, url } = created.body;
470
+
471
+ // 2. The file straight to Mux, chunked and resumable (UpChunk, as the template does).
472
+ const sent = await new Promise<boolean>((resolve) => {
473
+ signal.addEventListener('abort', () => resolve(false), { once: true });
474
+ import('@mux/upchunk')
475
+ .then(({ createUpload }) => {
476
+ if (signal.aborted) return resolve(false);
477
+ // 30 MB chunks (UpChunk counts in KB): few requests, and a retried chunk is still small.
478
+ const up = createUpload({ endpoint: url, file, chunkSize: 30 * 1024, attempts: 8, delayBeforeAttempt: 2 });
479
+ abortUpload = () => up.abort();
480
+ up.on('progress', (event) => patchUpload(id, { pct: Math.min(99, Math.round(Number(event.detail) || 0)) }));
481
+ up.on('error', (event) => {
482
+ const detail = event.detail as { message?: unknown } | undefined;
483
+ failed(`The upload to Mux stopped${typeof detail?.message === 'string' && detail.message ? `: ${detail.message}` : ''}. Try again.`);
484
+ resolve(false);
485
+ });
486
+ up.on('success', () => resolve(true));
487
+ })
488
+ .catch(() => {
489
+ failed('The uploader could not start. Reload the editor and try again.');
490
+ resolve(false);
491
+ });
492
+ });
493
+ if (!sent || signal.aborted) return;
494
+ patchUpload(id, { phase: 'proc', pct: 100 });
495
+
496
+ // 3. Poll the upload until Mux has made an asset from it, then the asset until it can play.
497
+ let assetId: string | null = null;
498
+ try {
499
+ while (!assetId) {
500
+ await wait(UPLOAD_POLL_MS, signal);
501
+ const status = await getJson<VideoUploadStatus>(`${MEDIA_ENDPOINTS.videoUpload}/${encodeURIComponent(uploadId)}`).catch(() => null);
502
+ if (!status || status.status !== 200 || !status.body) continue;
503
+ if (status.body.assetId) assetId = status.body.assetId;
504
+ else if (status.body.status === 'errored' || status.body.status === 'cancelled' || status.body.status === 'timed_out') {
505
+ failed(status.body.error ? `Mux couldn't take it: ${status.body.error}` : "Mux couldn't read it as a video. Export it again as an MP4 or MOV and upload that.");
506
+ return;
507
+ }
508
+ }
509
+ for (let first = true; ; first = false) {
510
+ if (!first) await wait(ASSET_POLL_MS, signal);
511
+ const result = await getJson<LibraryClip>(`${MEDIA_ENDPOINTS.clips}/${encodeURIComponent(assetId)}`).catch(() => null);
512
+ if (!result || result.status !== 200 || !result.body) continue;
513
+ const clip = result.body;
514
+ if (clip.errored) {
515
+ failed(clip.error ?? "Mux couldn't read it as a video. Export it again as an MP4 or MOV and upload that.");
516
+ return;
517
+ }
518
+ if (!clip.ready) continue;
519
+ dropUpload(id);
520
+ const list = clips.get().list ?? [];
521
+ clips.update({ list: [clip, ...list.filter((c) => c.id !== clip.id)] });
522
+ markFresh(clipId(clip));
523
+ if (slot) place(slot, clip);
524
+ return;
525
+ }
526
+ } catch {
527
+ // Cancelled.
528
+ }
529
+ })();
530
+ }
531
+
532
+ const library: MediaLibrary = {
533
+ images,
534
+ clips,
535
+ files,
536
+ uploads,
537
+ refused,
538
+ slotNotes,
539
+ showUnused,
540
+ fresh,
541
+ placed,
542
+ loadImages,
543
+ loadClips,
544
+ upload(files, slot = null) {
545
+ let slotTaken = !slot;
546
+ let refusedAtSlot: string | null = null;
547
+ for (const file of files) {
548
+ const kind = fileKind(file);
549
+ if (!slotTaken && slot) {
550
+ if (kind === 'clip') {
551
+ if (slot.only !== 'image') {
552
+ slotTaken = true;
553
+ noteSlot(slot, null);
554
+ startClip(file, slot);
555
+ continue;
556
+ }
557
+ // A clip on an image-only slot: said at the slot, unless a later file fits. Alone, it goes nowhere.
558
+ refusedAtSlot ??= VIDEO_ON_IMAGE_SLOT_MESSAGE;
559
+ if (files.length === 1) continue;
560
+ } else if (slot.only !== 'video') {
561
+ // An image, or a file whose type the name doesn't say: the server reads its bytes, and its refusal shows at the slot.
562
+ slotTaken = true;
563
+ noteSlot(slot, null);
564
+ startImage(file, slot);
565
+ continue;
566
+ }
567
+ }
568
+ // The library's own: an image to the bucket (the server says why one is refused), a clip to Mux.
569
+ if (kind === 'clip') {
570
+ startClip(file, null);
571
+ if (editor.view.get().level === 'media') editor.view.update({ media: { ...(editor.view.get().media ?? { pick: null, item: null, tab: 'clips' }), tab: 'clips' } });
572
+ } else startImage(file, null);
573
+ }
574
+ if (slot && !slotTaken && refusedAtSlot) noteSlot(slot, refusedAtSlot);
575
+ },
576
+ place,
577
+ async uploadFile(file, tag) {
578
+ const sent = await send(file, tag, null);
579
+ if (!sent.ok) return { ok: false, error: sent.aborted ? '' : sent.error };
580
+ if ('image' in sent) {
581
+ addImage(sent.image);
582
+ return { ok: false, error: 'A link takes a file, not an image.' };
583
+ }
584
+ addFile(sent.file);
585
+ return { ok: true, file: sent.file, created: sent.created };
586
+ },
587
+ setShowUnused(on) {
588
+ showUnused.set(on);
589
+ try {
590
+ window.localStorage.setItem(UNUSED_KEY(editor.site.storageKey), on ? '1' : '0');
591
+ } catch {
592
+ /* the toggle still works for this tab */
593
+ }
594
+ },
595
+ dismissRefused(id) {
596
+ refused.set((list) => list.filter((r) => r.id !== id));
597
+ },
598
+ dismissUpload(id) {
599
+ const cancel = cancels.get(id);
600
+ cancel?.();
601
+ dropUpload(id);
602
+ },
603
+ clearSlotNote(slot) {
604
+ noteSlot(slot, null);
605
+ },
606
+ };
607
+ made.set(editor, library);
608
+ return library;
609
+ }
610
+
611
+ /** Show unused: on by default (Decision 7), remembered per browser. */
612
+ function readShowUnused(site: string): boolean {
613
+ try {
614
+ return typeof window === 'undefined' ? true : window.localStorage.getItem(UNUSED_KEY(site)) !== '0';
615
+ } catch {
616
+ return true;
617
+ }
618
+ }