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,239 @@
1
+ /**
2
+ * A media slot's stored value (M5b Decision 3): an image or a Mux clip, parsed,
3
+ * written and resolved for the render. Browser-safe (only caracms/files, itself import-free): the editor's
4
+ * fields read it as the server does. URLs that need signing are NOT built here
5
+ * (that's `media.ts`, server-only); a clip's Mux URLs are fixed hosts, so they are.
6
+ *
7
+ * an image { "key": "uploads/…/harbour-wall.jpg", "alt": "…", "focal": [0.4, 0.3], "w": 4000, "h": 2667 }
8
+ * a clip { "video": "<playback ID>", "play": "loop", "poster": 2.5, "name": "…", "duration": 14.2, "w": 1920, "h": 1080 }
9
+ *
10
+ * - An image is an object with a non-empty string `key`; a clip one with a non-empty
11
+ * string `video`. Anything else parses as `null` (empty), and the serializer leaves
12
+ * it in the file untouched unless the field is edited (M3 Decision 7).
13
+ * - `alt` is always written (`""` when there's none). `decorative: true` only when
14
+ * true, after `alt`, and then `alt` is written as `""` (the alt kept as read until
15
+ * the field is written; decorative wins at render).
16
+ * - `focal` is `[x, y]`, fractions from the left and the top, 0–1, rounded to two
17
+ * places; half a pair, a value out of 0–1 or nonsense reads as unset (no warning).
18
+ * - A clip's `play` is `loop` (also what a missing or unknown value reads as) or `player`.
19
+ *
20
+ * Parsing is idempotent (a parsed value parses to itself), which the serializer's
21
+ * "touched?" comparison relies on.
22
+ */
23
+ import { isFileKey } from './files.ts';
24
+
25
+ export type MediaOnly = 'image' | 'video';
26
+ export type PlayAs = 'loop' | 'player';
27
+
28
+ export interface ImageValue {
29
+ key: string;
30
+ alt: string;
31
+ decorative?: true;
32
+ focal?: [number, number];
33
+ w?: number;
34
+ h?: number;
35
+ }
36
+
37
+ export interface ClipValue {
38
+ video: string;
39
+ play: PlayAs;
40
+ poster?: number;
41
+ name?: string;
42
+ duration?: number;
43
+ w?: number;
44
+ h?: number;
45
+ }
46
+
47
+ export type MediaValue = ImageValue | ClipValue;
48
+
49
+ /** What a block component gets for an image slot (fields.ts → renderValue). */
50
+ export interface RenderImage {
51
+ kind: 'image';
52
+ key: string;
53
+ /** `""` for a decorative image, whatever the file says (decorative wins). */
54
+ alt: string;
55
+ decorative: boolean;
56
+ focal: [number, number] | null;
57
+ /** `"40% 30%"` when there's a focal point, else null (no `object-position`). */
58
+ position: string | null;
59
+ w: number | null;
60
+ h: number | null;
61
+ }
62
+
63
+ /** What a block component gets for a clip slot. */
64
+ export interface RenderClip {
65
+ kind: 'video';
66
+ playbackId: string;
67
+ /** `loop` whenever the slot is loop-only (`media({ play: 'loop' })`). */
68
+ play: PlayAs;
69
+ name: string;
70
+ /** The MP4 a loop plays: Mux's `highest` static rendition. */
71
+ mp4: string;
72
+ /** Mux's still at `poster` seconds (0, the first frame, when unset), WebP at 1920. */
73
+ poster: string;
74
+ duration: number | null;
75
+ w: number | null;
76
+ h: number | null;
77
+ }
78
+
79
+ export type RenderMedia = RenderImage | RenderClip;
80
+
81
+ const isObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
82
+ const nonEmpty = (value: unknown): value is string => typeof value === 'string' && value.trim() !== '';
83
+ const size = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined);
84
+ const round2 = (n: number) => Math.round(n * 100) / 100;
85
+
86
+ /** A focal point as stored: both numbers finite and within 0–1, rounded to two places; otherwise unset. */
87
+ export function parseFocal(value: unknown): [number, number] | undefined {
88
+ if (!Array.isArray(value) || value.length !== 2) return undefined;
89
+ const [x, y] = value;
90
+ const ok = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1;
91
+ return ok(x) && ok(y) ? [round2(x), round2(y)] : undefined;
92
+ }
93
+
94
+ /** Is this an image value (an object with a string `key`)? */
95
+ export const isImageValue = (value: unknown): value is ImageValue => isObject(value) && nonEmpty(value.key);
96
+ /** Is this a clip value (an object with a string `video`, and no `key`)? */
97
+ export const isClipValue = (value: unknown): value is ClipValue => isObject(value) && !nonEmpty(value.key) && nonEmpty(value.video);
98
+
99
+ /** A raw value as a media value, or null for anything that isn't one. Never throws. */
100
+ export function parseMedia(raw: unknown): MediaValue | null {
101
+ if (!isObject(raw)) return null;
102
+ if (nonEmpty(raw.key)) {
103
+ const out: ImageValue = { key: raw.key.trim(), alt: typeof raw.alt === 'string' ? raw.alt : '' };
104
+ if (raw.decorative === true) out.decorative = true;
105
+ const focal = parseFocal(raw.focal);
106
+ if (focal) out.focal = focal;
107
+ const w = size(raw.w);
108
+ const h = size(raw.h);
109
+ if (w !== undefined) out.w = w;
110
+ if (h !== undefined) out.h = h;
111
+ return out;
112
+ }
113
+ if (nonEmpty(raw.video)) {
114
+ const out: ClipValue = { video: raw.video.trim(), play: raw.play === 'player' ? 'player' : 'loop' };
115
+ if (typeof raw.poster === 'number' && Number.isFinite(raw.poster) && raw.poster >= 0) out.poster = raw.poster;
116
+ if (nonEmpty(raw.name)) out.name = raw.name;
117
+ const duration = size(raw.duration);
118
+ const w = size(raw.w);
119
+ const h = size(raw.h);
120
+ if (duration !== undefined) out.duration = duration;
121
+ if (w !== undefined) out.w = w;
122
+ if (h !== undefined) out.h = h;
123
+ return out;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /** A media value in the order the file stores it (Decision 3): `null` for an empty slot. */
129
+ export function mediaToJson(value: unknown): Record<string, unknown> | null {
130
+ const v = parseMedia(value);
131
+ if (!v) return null;
132
+ if ('key' in v) {
133
+ const out: Record<string, unknown> = { key: v.key, alt: v.decorative ? '' : v.alt };
134
+ if (v.decorative) out.decorative = true;
135
+ if (v.focal) out.focal = v.focal;
136
+ if (v.w !== undefined) out.w = v.w;
137
+ if (v.h !== undefined) out.h = v.h;
138
+ return out;
139
+ }
140
+ const out: Record<string, unknown> = { video: v.video, play: v.play };
141
+ if (v.poster !== undefined) out.poster = v.poster;
142
+ if (v.name !== undefined) out.name = v.name;
143
+ if (v.duration !== undefined) out.duration = v.duration;
144
+ if (v.w !== undefined) out.w = v.w;
145
+ if (v.h !== undefined) out.h = v.h;
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * A media value as one line of JSON, the way the demo's files hold it:
151
+ * `{ "key": "…", "alt": "…", "focal": [0.4, 0.3], "w": 4000, "h": 2667 }`; `null` when empty.
152
+ */
153
+ export function mediaLine(value: unknown): string {
154
+ const stored = mediaToJson(value);
155
+ if (!stored) return 'null';
156
+ const part = (v: unknown) => (Array.isArray(v) ? `[${v.map((x) => JSON.stringify(x)).join(', ')}]` : JSON.stringify(v));
157
+ return `{ ${Object.entries(stored)
158
+ .map(([k, v]) => `${JSON.stringify(k)}: ${part(v)}`)
159
+ .join(', ')} }`;
160
+ }
161
+
162
+ /** Which kind a value is: `'image'`, `'video'`, or null when empty. */
163
+ export function mediaKind(value: unknown): MediaOnly | null {
164
+ const v = parseMedia(value);
165
+ return v ? ('key' in v ? 'image' : 'video') : null;
166
+ }
167
+
168
+ /**
169
+ * The slot's value after picking something new (Decision 3): an image replaces the
170
+ * whole value, not decorative (a new picture may carry meaning), its alt text and
171
+ * focal point the ones given (already used for it, Decision 7) or `""` and none. A clip swapped for a clip keeps its play mode and name (`video.md`) and
172
+ * resets its poster; a clip in an image's slot starts as a loop.
173
+ */
174
+ export function pickMedia(previous: unknown, picked: { key: string; w?: number; h?: number; alt?: string; focal?: [number, number] | null } | { video: string; duration?: number; w?: number; h?: number }): MediaValue {
175
+ if ('key' in picked) {
176
+ return parseMedia({ key: picked.key, alt: picked.alt ?? '', focal: picked.focal ?? undefined, w: picked.w, h: picked.h })!;
177
+ }
178
+ const prev = parseMedia(previous);
179
+ const clip = prev && 'video' in prev ? prev : null;
180
+ return parseMedia({ video: picked.video, play: clip?.play ?? 'loop', name: clip?.name, duration: picked.duration, w: picked.w, h: picked.h })!;
181
+ }
182
+
183
+ /** Mux's delivery and thumbnail hosts (fixed for every account). */
184
+ export const MUX_STREAM_URL = 'https://stream.mux.com';
185
+ export const MUX_IMAGE_URL = 'https://image.mux.com';
186
+ /** The poster's width: the ladder's top (Mux upscales on request, so it's a cap too). */
187
+ export const MUX_POSTER_WIDTH = 1920;
188
+
189
+ /** A clip's MP4: the `highest` static rendition (never `1080p`, which is billed). */
190
+ export const muxMp4 = (playbackId: string) => `${MUX_STREAM_URL}/${encodeURIComponent(playbackId)}/highest.mp4`;
191
+ /** A clip's poster: WebP at 1920, at `time` seconds (0, the first frame, when unset). Never the default PNG. */
192
+ export const muxPoster = (playbackId: string, time?: number) =>
193
+ `${MUX_IMAGE_URL}/${encodeURIComponent(playbackId)}/thumbnail.webp?width=${MUX_POSTER_WIDTH}&time=${typeof time === 'number' && Number.isFinite(time) && time >= 0 ? time : 0}`;
194
+
195
+ /**
196
+ * The render's value for a slot: an image or a clip resolved for the page, or null
197
+ * when the slot is empty or holds a kind it doesn't take (`only`). `play: 'loop'`
198
+ * forces a clip to loop. Takes a stored value, or an already-resolved one.
199
+ */
200
+ export function renderMedia(value: unknown, slot: { only?: MediaOnly; play?: 'loop' } = {}): RenderMedia | null {
201
+ // M6c G4 (Decision 4): a file's key written into a media value by hand is no image: nothing, never an imgproxy URL for a PDF.
202
+ if (isObject(value) && isFileKey(typeof value.key === 'string' ? value.key.trim() : null)) return null;
203
+ if (isObject(value) && (value.kind === 'image' || value.kind === 'video') && (typeof value.key === 'string' || typeof value.playbackId === 'string')) {
204
+ const resolved = value as unknown as RenderMedia;
205
+ if (slot.only && slot.only !== resolved.kind) return null;
206
+ return resolved.kind === 'video' && slot.play === 'loop' ? { ...resolved, play: 'loop' } : resolved;
207
+ }
208
+ const v = parseMedia(value);
209
+ if (!v) return null;
210
+ if ('key' in v) {
211
+ if (slot.only === 'video') return null;
212
+ const focal = v.focal ?? null;
213
+ return {
214
+ kind: 'image',
215
+ key: v.key,
216
+ alt: v.decorative ? '' : v.alt.trim(),
217
+ decorative: !!v.decorative,
218
+ focal,
219
+ position: focal ? `${Math.round(focal[0] * 100)}% ${Math.round(focal[1] * 100)}%` : null,
220
+ w: v.w ?? null,
221
+ h: v.h ?? null,
222
+ };
223
+ }
224
+ if (slot.only === 'image') return null;
225
+ return {
226
+ kind: 'video',
227
+ playbackId: v.video,
228
+ play: slot.play === 'loop' ? 'loop' : v.play,
229
+ name: v.name?.trim() ?? '',
230
+ mp4: muxMp4(v.video),
231
+ poster: muxPoster(v.video, v.poster),
232
+ duration: v.duration ?? null,
233
+ w: v.w ?? null,
234
+ h: v.h ?? null,
235
+ };
236
+ }
237
+
238
+ /** The message on an image with no alt text (Decision 9). */
239
+ export const MISSING_ALT_MESSAGE = "No alt text yet: people who can't see the picture get nothing";
package/src/media.ts ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * The image URL builder (M5b Decision 2): the ONE place that knows imgproxy's URL
3
+ * shape and signs it. **Server-only** (node:crypto, the signing key): the build and
4
+ * the preview's server render call it, never the browser. Nothing here is ever put
5
+ * in `virtual:caracms/config` or an editor prop: only the URLs it returns leave the
6
+ * server.
7
+ *
8
+ * https://<CARACMS_MEDIA_URL>/<sig>/rs:<fit|fill>:<w>:<h>[/g:fp:<x>:<y>]/q:80/plain/s3://<bucket>/<key>@webp
9
+ *
10
+ * - `<sig>` is imgproxy's HMAC-SHA256 over the salt then the path, in base64url
11
+ * (docs.imgproxy.net → Signing the URL). A path with no signature, or a wrong one,
12
+ * is refused (403).
13
+ * - A gravity goes only on a `fill` (a `fit` crops nothing; a gravity there would only
14
+ * make a second URL for the same file). Quality 80. WebP, always in the URL (never
15
+ * imgproxy's auto-format: Railway's CDN ignores `Vary: Accept`); `@png` when the
16
+ * media field says `format: 'png'` (M9 Decision 8).
17
+ * - Sides are rounded and capped at 4000 (imgproxy's `IMGPROXY_MAX_RESULT_DIMENSION`).
18
+ * - GIFs and SVGs pass through: `/<sig>/plain/s3://<bucket>/<key>`, no resize, no
19
+ * format, still signed (a GIF resize drops its animation; an SVG is sharp at any size).
20
+ *
21
+ * The variables (Decision 5): `CARACMS_MEDIA_URL` (imgproxy's public origin),
22
+ * `IMGPROXY_KEY` and `IMGPROXY_SALT` (hex, the values imgproxy has), `S3_BUCKET` (the
23
+ * bucket in the `s3://` source). `CARACMS_MEDIA=memory` supplies `https://img.test`, a
24
+ * fixed test key and salt and a test bucket, and is refused on a production server.
25
+ */
26
+ import { createHmac } from 'node:crypto';
27
+ import type { ImageFormat } from './fields.ts';
28
+ import type { RenderImage } from './media-value.ts';
29
+
30
+ /** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
31
+ export const IMAGE_WIDTHS: readonly number[] = [160, 320, 480, 640, 960, 1280, 1920];
32
+ /** imgproxy's IMGPROXY_MAX_RESULT_DIMENSION: a side is never asked for above it. */
33
+ export const IMAGE_MAX_DIMENSION = 4000;
34
+ export const IMAGE_QUALITY = 80;
35
+ /** Pinned in every resized URL, unless the media field asks for PNG (M9 Decision 8). */
36
+ export const IMAGE_FORMAT: ImageFormat = 'webp';
37
+ /** Fixed mode's densities (Decision 4: 1×, 2×, 3×). */
38
+ export const IMAGE_DENSITIES: readonly number[] = [1, 2, 3];
39
+
40
+ /** `CARACMS_MEDIA=memory`'s host, key, salt and bucket (tests only; Decision 5). */
41
+ export const MEMORY_MEDIA = {
42
+ origin: 'https://img.test',
43
+ key: '7465737420696d6770726f7879206b6579',
44
+ salt: '7465737420696d6770726f78792073616c74',
45
+ bucket: 'caracms-test-media',
46
+ } as const;
47
+
48
+ export interface MediaSettings {
49
+ /** imgproxy's public origin, no trailing slash. */
50
+ origin: string;
51
+ /** Hex, as imgproxy takes them. */
52
+ key: string;
53
+ salt: string;
54
+ bucket: string;
55
+ memory: boolean;
56
+ }
57
+
58
+ export type MediaConfig =
59
+ | { kind: 'ready'; settings: MediaSettings }
60
+ /** `missing` names the variables that aren't set; `problems` are sentences for anything else wrong. */
61
+ | { kind: 'unset'; missing: string[]; problems: string[] };
62
+
63
+ declare const __CARACMS_PRODUCTION__: boolean | undefined;
64
+
65
+ /** True in the build and the built server (the integration's define), false under `astro dev` and in plain Node. */
66
+ export function isProductionBuild(): boolean {
67
+ return typeof __CARACMS_PRODUCTION__ === 'boolean' ? __CARACMS_PRODUCTION__ : false;
68
+ }
69
+
70
+ type Env = Record<string, string | undefined>;
71
+ const HEX = /^(?:[0-9a-fA-F]{2})+$/;
72
+
73
+ /** The media variables (Decision 5). Read on each call (cheap), so tests can change them. */
74
+ export function readMediaConfig(env: Env = process.env, production = isProductionBuild()): MediaConfig {
75
+ const get = (name: string) => {
76
+ const v = env[name]?.trim();
77
+ return v ? v : null;
78
+ };
79
+ const problems: string[] = [];
80
+ const mode = get('CARACMS_MEDIA');
81
+ if (mode === 'memory') {
82
+ if (production) return { kind: 'unset', missing: [], problems: ['CARACMS_MEDIA=memory is for tests and local runs; a production server never uses the in-memory media.'] };
83
+ return { kind: 'ready', settings: { ...MEMORY_MEDIA, memory: true } };
84
+ }
85
+ if (mode) problems.push(`CARACMS_MEDIA must be "memory" or unset (got "${mode}").`);
86
+ const missing: string[] = [];
87
+ const need = (name: string) => {
88
+ const v = get(name);
89
+ if (!v) missing.push(name);
90
+ return v ?? '';
91
+ };
92
+ const origin = need('CARACMS_MEDIA_URL').replace(/\/+$/, '');
93
+ const key = need('IMGPROXY_KEY');
94
+ const salt = need('IMGPROXY_SALT');
95
+ const bucket = need('S3_BUCKET');
96
+ if (origin && !/^https?:\/\/[^/]+$/.test(origin)) problems.push(`CARACMS_MEDIA_URL must be an origin like https://img.example.com (got "${origin}").`);
97
+ if (key && !HEX.test(key)) problems.push('IMGPROXY_KEY must be hex (the same value imgproxy has).');
98
+ if (salt && !HEX.test(salt)) problems.push('IMGPROXY_SALT must be hex (the same value imgproxy has).');
99
+ if (missing.length || problems.length) return { kind: 'unset', missing, problems };
100
+ return { kind: 'ready', settings: { origin, key, salt, bucket, memory: false } };
101
+ }
102
+
103
+ const FAILED = Symbol.for('caracms.mediaUnsetThrown');
104
+ /** True after the first call in this process: a build without the variables throws its sentence once (Media.astro). */
105
+ export function failedOnce(): boolean {
106
+ const g = globalThis as { [FAILED]?: true };
107
+ if (g[FAILED]) return true;
108
+ g[FAILED] = true;
109
+ return false;
110
+ }
111
+
112
+ /** The one sentence a render without the variables gives (Decision 5). */
113
+ export function mediaUnsetMessage(config: Extract<MediaConfig, { kind: 'unset' }>): string {
114
+ const names = config.missing;
115
+ const list = names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
116
+ const missing = names.length ? `Images can't be built without ${list}: set ${names.length === 1 ? 'it' : 'them'} to the site's imgproxy and media bucket.` : '';
117
+ return [missing, ...config.problems].filter(Boolean).join(' ');
118
+ }
119
+
120
+ /** imgproxy's signature for a path: HMAC-SHA256 with the key, over the salt then the path, base64url. */
121
+ export function signPath(path: string, keyHex: string, saltHex: string): string {
122
+ return createHmac('sha256', Buffer.from(keyHex, 'hex')).update(Buffer.from(saltHex, 'hex')).update(path).digest('base64url');
123
+ }
124
+
125
+ /** Does imgproxy resize this key at all? GIFs and SVGs pass through (Decision 2). */
126
+ export const isResizable = (key: string): boolean => !/\.(gif|svg)$/i.test(key);
127
+
128
+ /** A side, rounded and capped; 0 for "not set". */
129
+ function side(value: number | undefined): number {
130
+ if (!value || !Number.isFinite(value) || value <= 0) return 0;
131
+ return Math.min(Math.round(value), IMAGE_MAX_DIMENSION);
132
+ }
133
+
134
+ /** A key as imgproxy's `plain` source takes it: each part percent-encoded, slashes kept. */
135
+ const sourceOf = (settings: MediaSettings, key: string) => `s3://${settings.bucket}/${key.split('/').map(encodeURIComponent).join('/')}`;
136
+
137
+ const signed = (settings: MediaSettings, path: string) => `${settings.origin}/${signPath(path, settings.key, settings.salt)}${path}`;
138
+
139
+ export interface ImageUrlOptions {
140
+ width?: number;
141
+ height?: number;
142
+ /** Default: `fill` with both sides, else `fit`. */
143
+ resize?: 'fit' | 'fill';
144
+ /** The focal point, fractions; used only on a `fill`. */
145
+ focal?: readonly [number, number] | null;
146
+ /** Default: WebP (M9 Decision 8). */
147
+ format?: ImageFormat;
148
+ }
149
+
150
+ /**
151
+ * One signed URL for a key at one size and shape. A GIF or an SVG gets its
152
+ * pass-through URL whatever the options; anything else needs a width or a height.
153
+ */
154
+ export function imageUrl(settings: MediaSettings, key: string, options: ImageUrlOptions = {}): string {
155
+ if (!isResizable(key)) return signed(settings, `/plain/${sourceOf(settings, key)}`);
156
+ const width = side(options.width);
157
+ const height = side(options.height);
158
+ if (!width && !height) throw new Error(`An image URL needs a width or a height (${key}).`);
159
+ const resize = options.resize ?? (width && height ? 'fill' : 'fit');
160
+ const gravity = resize === 'fill' && options.focal ? `/g:fp:${options.focal[0]}:${options.focal[1]}` : '';
161
+ return signed(settings, `/rs:${resize}:${width}:${height}${gravity}/q:${IMAGE_QUALITY}/plain/${sourceOf(settings, key)}@${options.format ?? IMAGE_FORMAT}`);
162
+ }
163
+
164
+ /** A slot's part of the ladder: every rung from `min` to `max`, inclusive. */
165
+ export function imageWidths(min = IMAGE_WIDTHS[0]!, max = IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1]!): number[] {
166
+ const widths = IMAGE_WIDTHS.filter((w) => w >= min && w <= max);
167
+ if (widths.length === 0) throw new Error(`No ladder width between ${min} and ${max} (ladder: ${IMAGE_WIDTHS.join(', ')}).`);
168
+ return widths;
169
+ }
170
+
171
+ /** A library thumbnail (Phase 2): a `fit` at one rung of the ladder (320 by default), so it shares the page's cache. */
172
+ export function thumbnailUrl(settings: MediaSettings, key: string, width = 320): string {
173
+ return imageUrl(settings, key, { width });
174
+ }
175
+
176
+ /** What an `<img>` gets: `src`, the `srcset` and `sizes` (none for a pass-through), and its `width`/`height`. */
177
+ export interface ImageAttributes {
178
+ src: string;
179
+ srcset?: string;
180
+ sizes?: string;
181
+ width?: number;
182
+ height?: number;
183
+ }
184
+
185
+ export interface FluidOptions {
186
+ sizes: string;
187
+ /** width / height: crop every rung to it (`rs:fill` with the focal point's gravity). None: `rs:fit`. */
188
+ ratio?: number;
189
+ minWidth?: number;
190
+ maxWidth?: number;
191
+ format?: ImageFormat;
192
+ }
193
+
194
+ export interface FixedOptions {
195
+ /** CSS px. One side: `fit`. Both: `fill`, or `fit` with `fit: 'contain'`. */
196
+ width?: number;
197
+ height?: number;
198
+ fit?: 'cover' | 'contain';
199
+ densities?: readonly number[];
200
+ format?: ImageFormat;
201
+ }
202
+
203
+ const naturalHeight = (image: Pick<RenderImage, 'w' | 'h'>, width: number) => (image.w && image.h ? Math.round((width * image.h) / image.w) : undefined);
204
+ const naturalWidth = (image: Pick<RenderImage, 'w' | 'h'>, height: number) => (image.w && image.h ? Math.round((height * image.w) / image.h) : undefined);
205
+
206
+ /**
207
+ * A fluid image (the width follows the viewport): a `w` srcset over the ladder.
208
+ * `width`/`height` are the stored size, capped at the top rung and scaled to the ratio,
209
+ * so the browser reserves the box (Decision 4).
210
+ */
211
+ export function fluidImage(settings: MediaSettings, image: Pick<RenderImage, 'key' | 'focal' | 'w' | 'h'>, options: FluidOptions): ImageAttributes {
212
+ const widths = imageWidths(options.minWidth, options.maxWidth);
213
+ const top = widths[widths.length - 1]!;
214
+ const shownWidth = image.w ? Math.min(image.w, top) : options.ratio ? top : undefined;
215
+ const dims = (w: number | undefined) => {
216
+ if (!w) return {};
217
+ const h = options.ratio ? Math.round(w / options.ratio) : naturalHeight(image, w);
218
+ return h ? { width: w, height: h } : {};
219
+ };
220
+ if (!isResizable(image.key)) return { src: imageUrl(settings, image.key), ...dims(image.w ?? undefined) };
221
+ const at = (w: number) =>
222
+ options.ratio
223
+ ? imageUrl(settings, image.key, { width: w, height: Math.round(w / options.ratio), focal: image.focal, format: options.format })
224
+ : imageUrl(settings, image.key, { width: w, format: options.format });
225
+ return {
226
+ src: at(top),
227
+ srcset: widths.map((w) => `${at(w)} ${w}w`).join(', '),
228
+ sizes: options.sizes,
229
+ ...dims(shownWidth),
230
+ };
231
+ }
232
+
233
+ /** A fixed image (a set CSS size: a logo, an icon): an `x` srcset at 1×, 2×, 3×. */
234
+ export function fixedImage(settings: MediaSettings, image: Pick<RenderImage, 'key' | 'focal' | 'w' | 'h'>, options: FixedOptions): ImageAttributes {
235
+ const { width, height } = options;
236
+ if (!width && !height) throw new Error(`A fixed image needs a width or a height (${image.key}).`);
237
+ const box = { width: width ?? naturalWidth(image, height!), height: height ?? naturalHeight(image, width!) };
238
+ const dims = box.width && box.height ? { width: box.width, height: box.height } : {};
239
+ if (!isResizable(image.key)) return { src: imageUrl(settings, image.key), ...dims };
240
+ const both = !!width && !!height;
241
+ const resize = both && options.fit !== 'contain' ? 'fill' : 'fit';
242
+ const at = (d: number) => imageUrl(settings, image.key, { width: width ? width * d : 0, height: height ? height * d : 0, resize, focal: image.focal, format: options.format });
243
+ const densities = options.densities ?? IMAGE_DENSITIES;
244
+ return { src: at(densities[0] ?? 1), srcset: densities.map((d) => `${at(d)} ${d}x`).join(', '), ...dims };
245
+ }
246
+
247
+ /**
248
+ * One fixed-mode URL for a brand slot (Decision 11): the logo or the tab icon, `fit`
249
+ * at `cssHeight` × 2 (sharp on a 2× screen; the editor's `<img src>` and the icon's
250
+ * `href` take one URL). An SVG passes through. Null when the media isn't set up.
251
+ */
252
+ export function brandImageUrl(key: string, cssHeight: number, config: MediaConfig = readMediaConfig()): string | null {
253
+ if (config.kind !== 'ready') return null;
254
+ return imageUrl(config.settings, key, { height: cssHeight * 2 });
255
+ }