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,241 @@
1
+ /**
2
+ * Mux, as the template does it (M5b Decision 12; `tina-astro-template/server/mux.mjs`
3
+ * and `docs/video.md`, whose rules carry over exactly):
4
+ *
5
+ * - **Every upload gets the same settings** (`NEW_ASSET_SETTINGS`): public playback,
6
+ * `basic` quality, the `1080p` tier, a `highest` static rendition (never `1080p`, which
7
+ * is billed) and generated English captions.
8
+ * - **A clip is placeable only when it can play**: the asset is `ready` and its `highest`
9
+ * MP4 is `ready` (`toClip` → `ready`). A ready asset whose MP4 is missing, errored or
10
+ * skipped can never become ready, so it's errored with a sentence a designer can act on.
11
+ * - **The token never reaches the browser**, and **Mux's error bodies go to the server
12
+ * log only**: the browser gets a short sentence of our own (a full free plan gets
13
+ * `planLimitMessage`).
14
+ * - Plain `fetch` with basic auth (no SDK). The fake Mux behind `CARACMS_MEDIA=memory` is
15
+ * a `fetch` that answers Mux's API (memory.ts → `MemoryMux`), so it runs this same code.
16
+ */
17
+ import type { VideoUploadStatus } from '../../types.ts';
18
+
19
+ export const MUX_API = 'https://api.mux.com';
20
+
21
+ /** Every upload's asset settings (`video.md` → Upload settings). `meta.title` is added per upload. */
22
+ export const NEW_ASSET_SETTINGS = Object.freeze({
23
+ playback_policy: ['public'],
24
+ video_quality: 'basic',
25
+ max_resolution_tier: '1080p',
26
+ static_renditions: [{ resolution: 'highest' }],
27
+ inputs: [{ generated_subtitles: [{ language_code: 'en', name: 'English (generated)' }] }],
28
+ });
29
+
30
+ /** Mux ids are alphanumeric; anything else never reaches Mux's URL. */
31
+ export const MUX_ID = /^[A-Za-z0-9]{1,128}$/;
32
+ const PAGE_LIMIT = 100;
33
+ /** A runaway-cursor guard: 100 pages is 10,000 clips, far past one site's library. */
34
+ const MAX_PAGES = 100;
35
+ const MUX_TIMEOUT_MS = 15_000;
36
+ /** Mux caps meta.title at 512 characters. */
37
+ export const TITLE_MAX = 512;
38
+
39
+ export const NO_MP4_MESSAGE = 'No MP4 version of this clip. Upload it through the editor, or turn on MP4 (static renditions) for it in Mux.';
40
+ export const MP4_FAILED_MESSAGE = "Mux couldn't make the MP4 version of this clip. Upload it again through the editor, or delete it in the Mux dashboard.";
41
+
42
+ /** A Mux call that failed: `status` and `message` are what the browser sees. */
43
+ export class MuxRouteError extends Error {
44
+ readonly status: number;
45
+ constructor(status: number, message: string) {
46
+ super(message);
47
+ this.status = status;
48
+ }
49
+ }
50
+
51
+ /** A clip as the library gives it, before where-used is added. */
52
+ export interface Clip {
53
+ id: string;
54
+ playbackId: string | null;
55
+ status: string;
56
+ ready: boolean;
57
+ errored: boolean;
58
+ error: string | null;
59
+ title: string | null;
60
+ duration: number | null;
61
+ aspectRatio: string | null;
62
+ w: number | null;
63
+ h: number | null;
64
+ createdAt: string | null;
65
+ }
66
+
67
+ export interface MuxClient {
68
+ createUpload(origin: string, title: string): Promise<{ id: string; url: string }>;
69
+ upload(id: string): Promise<VideoUploadStatus>;
70
+ clip(assetId: string): Promise<Clip>;
71
+ /** The clip a playback ID plays; another environment's or an unknown one is a 404. */
72
+ clipOfPlayback(playbackId: string): Promise<Clip>;
73
+ /** The whole library, following Mux's cursor, newest first. */
74
+ clips(): Promise<Clip[]>;
75
+ }
76
+
77
+ type Json = any;
78
+
79
+ export function muxClient(options: { tokenId: string; tokenSecret: string; fetch?: typeof fetch; log?: (line: string) => void }): MuxClient {
80
+ const auth = 'Basic ' + Buffer.from(`${options.tokenId}:${options.tokenSecret}`).toString('base64');
81
+ const doFetch = options.fetch ?? ((...args: Parameters<typeof fetch>) => globalThis.fetch(...args));
82
+ const log = options.log ?? ((line: string) => console.error(line));
83
+
84
+ /** One call; resolves Mux's whole JSON reply (`{ data, next_cursor? }`). `notFoundOn`: statuses that mean "no such thing". */
85
+ async function mux(path: string, { method = 'GET', body, notFoundOn = [] as number[] }: { method?: string; body?: unknown; notFoundOn?: number[] } = {}): Promise<Json> {
86
+ const where = `${method} ${path.split('?')[0]}`;
87
+ let res: Response;
88
+ try {
89
+ res = await doFetch(MUX_API + path, {
90
+ method,
91
+ headers: { Authorization: auth, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
92
+ body: body === undefined ? undefined : JSON.stringify(body),
93
+ signal: AbortSignal.timeout(MUX_TIMEOUT_MS),
94
+ });
95
+ } catch (error) {
96
+ log(`[caracms] mux ${where} unreachable: ${error instanceof Error ? error.message : String(error)}`);
97
+ throw new MuxRouteError(502, 'Could not reach Mux.');
98
+ }
99
+ const text = await res.text().catch(() => '');
100
+ if (!res.ok) {
101
+ // Mux's body goes to the server log (it never holds the token), never to the browser.
102
+ log(`[caracms] mux ${where} ${res.status} ${text.slice(0, 500)}`);
103
+ if (notFoundOn.includes(res.status)) throw new MuxRouteError(404, 'Not found.');
104
+ if (res.status === 401 || res.status === 403) throw new MuxRouteError(502, 'Mux refused the token (check MUX_TOKEN_ID and MUX_TOKEN_SECRET).');
105
+ const full = planLimitMessage(text);
106
+ if (full) throw new MuxRouteError(502, full);
107
+ throw new MuxRouteError(502, `Mux request failed (${res.status}).`);
108
+ }
109
+ try {
110
+ const json = JSON.parse(text) as Json;
111
+ if (json && typeof json === 'object' && json.data !== undefined) return json;
112
+ } catch {
113
+ /* below */
114
+ }
115
+ log(`[caracms] mux ${where}: unreadable reply`);
116
+ throw new MuxRouteError(502, 'Mux sent an unreadable reply.');
117
+ }
118
+
119
+ const clip = async (assetId: string) => toClip((await mux(`/video/v1/assets/${assetId}`, { notFoundOn: [400, 404] })).data);
120
+
121
+ return {
122
+ async createUpload(origin, title) {
123
+ const { data } = await mux('/video/v1/uploads', { method: 'POST', body: { cors_origin: origin, new_asset_settings: { ...NEW_ASSET_SETTINGS, meta: { title } } } });
124
+ if (typeof data?.id !== 'string' || typeof data?.url !== 'string') throw new MuxRouteError(502, 'Mux sent an unreadable reply.');
125
+ return { id: data.id, url: data.url };
126
+ },
127
+ async upload(id) {
128
+ const { data } = await mux(`/video/v1/uploads/${id}`, { notFoundOn: [400, 404] });
129
+ return {
130
+ id: String(data?.id ?? id),
131
+ status: typeof data?.status === 'string' ? data.status : 'unknown',
132
+ assetId: typeof data?.asset_id === 'string' ? data.asset_id : null,
133
+ error: data?.status === 'errored' ? shortMessage(data.error) : null,
134
+ };
135
+ },
136
+ clip,
137
+ async clipOfPlayback(playbackId) {
138
+ // 400 is Mux's "mismatching environment": another environment's ID is a 404 here.
139
+ const { data } = await mux(`/video/v1/playback-ids/${playbackId}`, { notFoundOn: [400, 404] });
140
+ if (data?.object?.type !== 'asset' || typeof data.object.id !== 'string' || !MUX_ID.test(data.object.id)) throw new MuxRouteError(404, 'Not found.');
141
+ return clip(data.object.id);
142
+ },
143
+ async clips() {
144
+ const assets: Json[] = [];
145
+ let cursor: string | null = null;
146
+ const seen = new Set<string>();
147
+ for (let page = 0; ; page++) {
148
+ if (page === MAX_PAGES) {
149
+ log(`[caracms] mux: the list stopped after ${MAX_PAGES} pages`);
150
+ break;
151
+ }
152
+ const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
153
+ if (cursor) query.set('cursor', cursor);
154
+ const reply = await mux(`/video/v1/assets?${query}`);
155
+ if (Array.isArray(reply.data)) assets.push(...reply.data);
156
+ cursor = typeof reply.next_cursor === 'string' && reply.next_cursor ? reply.next_cursor : null;
157
+ if (!cursor || seen.has(cursor)) break;
158
+ seen.add(cursor);
159
+ }
160
+ return (
161
+ assets
162
+ .map(toClip)
163
+ // Errored clips stay even without a public playback ID: a failed upload must be visible.
164
+ .filter((c) => c.playbackId !== null || c.errored)
165
+ .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
166
+ );
167
+ },
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Mux's asset → the library's clip (the template's `toClip`, plus the video track's
173
+ * size). `ready` needs both the asset and its `highest` MP4. Failure is the asset's own
174
+ * status, never a track's (a silent clip's generated captions error; it still plays),
175
+ * plus a ready asset whose MP4 is missing, errored or skipped.
176
+ */
177
+ export function toClip(asset: Json): Clip {
178
+ const playback = Array.isArray(asset?.playback_ids) ? asset.playback_ids : [];
179
+ const publicId = playback.find((p: Json) => p?.policy === 'public' && typeof p.id === 'string');
180
+ const files = Array.isArray(asset?.static_renditions?.files) ? asset.static_renditions.files : [];
181
+ const highest = files.find((f: Json) => f?.resolution === 'highest');
182
+ const status = typeof asset?.status === 'string' ? asset.status : 'unknown';
183
+ let error: string | null = null;
184
+ if (status === 'errored') error = shortMessage(asset?.errors);
185
+ else if (status === 'ready' && !highest) error = NO_MP4_MESSAGE;
186
+ else if (status === 'ready' && (highest.status === 'errored' || highest.status === 'skipped')) error = MP4_FAILED_MESSAGE;
187
+ const seconds = Number(asset?.created_at);
188
+ const video = (Array.isArray(asset?.tracks) ? asset.tracks : []).find((t: Json) => t?.type === 'video');
189
+ const px = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
190
+ return {
191
+ id: String(asset?.id ?? ''),
192
+ playbackId: publicId ? publicId.id : null,
193
+ status,
194
+ ready: status === 'ready' && highest?.status === 'ready',
195
+ errored: error !== null,
196
+ error,
197
+ title: typeof asset?.meta?.title === 'string' && asset.meta.title ? asset.meta.title : null,
198
+ duration: typeof asset?.duration === 'number' ? asset.duration : null,
199
+ aspectRatio: typeof asset?.aspect_ratio === 'string' ? asset.aspect_ratio : null,
200
+ w: px(video?.max_width),
201
+ h: px(video?.max_height),
202
+ createdAt: asset?.created_at != null && Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : null,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * What a designer is told when Mux refuses because the plan is full (a new environment
208
+ * starts on the free plan, capped at 10 videos). Mux's own words are matched, not
209
+ * trusted, and none are carried over. Null for any other body.
210
+ */
211
+ export function planLimitMessage(text: string): string | null {
212
+ let messages: unknown;
213
+ try {
214
+ messages = (JSON.parse(text) as Json)?.error?.messages;
215
+ } catch {
216
+ return null;
217
+ }
218
+ if (!Array.isArray(messages)) return null;
219
+ for (const message of messages) {
220
+ if (typeof message === 'string' && /plan is limited to \d+ assets/i.test(message)) return 'The Mux plan is full. Ask your developer.';
221
+ }
222
+ return null;
223
+ }
224
+
225
+ /** Mux's reason for a failed asset or upload, short: `type: first message`. */
226
+ export function shortMessage(error: Json): string {
227
+ if (!error || typeof error !== 'object') return 'Mux could not process this file.';
228
+ const type = typeof error.type === 'string' ? error.type : '';
229
+ const message = Array.isArray(error.messages) ? error.messages[0] : error.message;
230
+ const text = [type, typeof message === 'string' ? message : ''].filter(Boolean).join(': ');
231
+ return (text || 'Mux could not process this file.').slice(0, 200);
232
+ }
233
+
234
+ /** The upload's title from `{ name }`: the file name, stripped of any path and control characters, capped at Mux's 512. */
235
+ export function clipTitle(name: unknown): string {
236
+ const raw = typeof name === 'string' ? name : '';
237
+ return (raw.split(/[\\/]/).pop() ?? '')
238
+ .replace(/[\u0000-\u001f\u007f]/g, '')
239
+ .trim()
240
+ .slice(0, TITLE_MAX);
241
+ }