@glw907/cairn-cms 0.56.1 → 0.57.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 (186) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -4
  3. package/dist/components/AdminLayout.svelte +3 -0
  4. package/dist/components/CairnAdmin.svelte +8 -1
  5. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  6. package/dist/components/CairnMediaLibrary.svelte +929 -0
  7. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  8. package/dist/components/ComponentForm.svelte +175 -46
  9. package/dist/components/ComponentForm.svelte.d.ts +22 -8
  10. package/dist/components/ComponentInsertDialog.svelte +379 -26
  11. package/dist/components/ComponentInsertDialog.svelte.d.ts +31 -2
  12. package/dist/components/EditPage.svelte +477 -15
  13. package/dist/components/EditPage.svelte.d.ts +2 -0
  14. package/dist/components/MarkdownEditor.svelte +358 -1
  15. package/dist/components/MarkdownEditor.svelte.d.ts +51 -1
  16. package/dist/components/MediaCaptureCard.svelte +135 -0
  17. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  18. package/dist/components/MediaFigureControl.svelte +247 -0
  19. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  20. package/dist/components/MediaHeroField.svelte +569 -0
  21. package/dist/components/MediaHeroField.svelte.d.ts +67 -0
  22. package/dist/components/MediaInsertPopover.svelte +449 -0
  23. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  24. package/dist/components/MediaPicker.svelte +257 -0
  25. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  26. package/dist/components/admin-icons.d.ts +12 -0
  27. package/dist/components/admin-icons.js +12 -0
  28. package/dist/components/cairn-admin.css +1045 -28
  29. package/dist/components/client-ingest.d.ts +142 -0
  30. package/dist/components/client-ingest.js +297 -0
  31. package/dist/components/editor-media.d.ts +11 -0
  32. package/dist/components/editor-media.js +206 -0
  33. package/dist/components/editor-placeholder.d.ts +26 -0
  34. package/dist/components/editor-placeholder.js +166 -0
  35. package/dist/components/index.d.ts +1 -0
  36. package/dist/components/index.js +1 -0
  37. package/dist/components/markdown-directives.d.ts +19 -0
  38. package/dist/components/markdown-directives.js +52 -0
  39. package/dist/components/markdown-format.d.ts +89 -0
  40. package/dist/components/markdown-format.js +255 -0
  41. package/dist/components/media-upload-outcome.d.ts +52 -0
  42. package/dist/components/media-upload-outcome.js +48 -0
  43. package/dist/content/compose.js +3 -0
  44. package/dist/content/frontmatter.js +17 -0
  45. package/dist/content/manifest.d.ts +4 -0
  46. package/dist/content/manifest.js +41 -1
  47. package/dist/content/media-refs.d.ts +7 -0
  48. package/dist/content/media-refs.js +52 -0
  49. package/dist/content/schema.d.ts +5 -2
  50. package/dist/content/schema.js +17 -0
  51. package/dist/content/types.d.ts +62 -11
  52. package/dist/content/validate.js +27 -0
  53. package/dist/delivery/public-routes.d.ts +16 -0
  54. package/dist/delivery/public-routes.js +46 -3
  55. package/dist/delivery/seo-fields.js +7 -1
  56. package/dist/delivery/seo.d.ts +2 -0
  57. package/dist/delivery/seo.js +3 -0
  58. package/dist/doctor/checks-local.d.ts +1 -0
  59. package/dist/doctor/checks-local.js +21 -0
  60. package/dist/doctor/index.d.ts +3 -1
  61. package/dist/doctor/index.js +11 -2
  62. package/dist/doctor/types.d.ts +3 -0
  63. package/dist/doctor/wrangler-config.d.ts +3 -0
  64. package/dist/doctor/wrangler-config.js +20 -0
  65. package/dist/env.d.ts +19 -0
  66. package/dist/env.js +26 -0
  67. package/dist/index.d.ts +1 -1
  68. package/dist/log/events.d.ts +1 -1
  69. package/dist/media/config.d.ts +24 -0
  70. package/dist/media/config.js +69 -0
  71. package/dist/media/delivery-bucket.d.ts +34 -0
  72. package/dist/media/delivery-bucket.js +10 -0
  73. package/dist/media/index.d.ts +6 -0
  74. package/dist/media/index.js +13 -0
  75. package/dist/media/library-entry.d.ts +30 -0
  76. package/dist/media/library-entry.js +17 -0
  77. package/dist/media/manifest.d.ts +44 -0
  78. package/dist/media/manifest.js +105 -0
  79. package/dist/media/naming.d.ts +18 -0
  80. package/dist/media/naming.js +112 -0
  81. package/dist/media/reconcile.d.ts +36 -0
  82. package/dist/media/reconcile.js +45 -0
  83. package/dist/media/reference.d.ts +12 -0
  84. package/dist/media/reference.js +33 -0
  85. package/dist/media/sniff.d.ts +18 -0
  86. package/dist/media/sniff.js +106 -0
  87. package/dist/media/store.d.ts +25 -0
  88. package/dist/media/store.js +16 -0
  89. package/dist/media/transform-url.d.ts +26 -0
  90. package/dist/media/transform-url.js +38 -0
  91. package/dist/media/usage.d.ts +48 -0
  92. package/dist/media/usage.js +90 -0
  93. package/dist/render/component-grammar.d.ts +20 -0
  94. package/dist/render/component-grammar.js +47 -3
  95. package/dist/render/component-validate.js +22 -0
  96. package/dist/render/pipeline.d.ts +2 -0
  97. package/dist/render/pipeline.js +13 -2
  98. package/dist/render/registry.d.ts +28 -0
  99. package/dist/render/registry.js +15 -0
  100. package/dist/render/remark-figure.d.ts +4 -0
  101. package/dist/render/remark-figure.js +103 -0
  102. package/dist/render/resolve-media.d.ts +34 -0
  103. package/dist/render/resolve-media.js +78 -0
  104. package/dist/render/sanitize-schema.d.ts +4 -2
  105. package/dist/render/sanitize-schema.js +5 -3
  106. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  107. package/dist/sveltekit/admin-dispatch.js +5 -0
  108. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  109. package/dist/sveltekit/cairn-admin.js +10 -2
  110. package/dist/sveltekit/content-routes.d.ts +68 -2
  111. package/dist/sveltekit/content-routes.js +461 -10
  112. package/dist/sveltekit/csrf.d.ts +16 -0
  113. package/dist/sveltekit/csrf.js +18 -0
  114. package/dist/sveltekit/guard.js +10 -3
  115. package/dist/sveltekit/index.d.ts +2 -1
  116. package/dist/sveltekit/index.js +1 -0
  117. package/dist/sveltekit/media-route.d.ts +12 -0
  118. package/dist/sveltekit/media-route.js +137 -0
  119. package/dist/vite/index.d.ts +3 -0
  120. package/dist/vite/index.js +7 -2
  121. package/package.json +8 -1
  122. package/src/lib/components/AdminLayout.svelte +3 -0
  123. package/src/lib/components/CairnAdmin.svelte +8 -1
  124. package/src/lib/components/CairnMediaLibrary.svelte +929 -0
  125. package/src/lib/components/ComponentForm.svelte +175 -46
  126. package/src/lib/components/ComponentInsertDialog.svelte +379 -26
  127. package/src/lib/components/EditPage.svelte +477 -15
  128. package/src/lib/components/MarkdownEditor.svelte +358 -1
  129. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  130. package/src/lib/components/MediaFigureControl.svelte +247 -0
  131. package/src/lib/components/MediaHeroField.svelte +569 -0
  132. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  133. package/src/lib/components/MediaPicker.svelte +257 -0
  134. package/src/lib/components/admin-icons.ts +12 -0
  135. package/src/lib/components/cairn-admin.css +37 -0
  136. package/src/lib/components/client-ingest.ts +380 -0
  137. package/src/lib/components/editor-media.ts +248 -0
  138. package/src/lib/components/editor-placeholder.ts +213 -0
  139. package/src/lib/components/index.ts +1 -0
  140. package/src/lib/components/markdown-directives.ts +57 -0
  141. package/src/lib/components/markdown-format.ts +307 -1
  142. package/src/lib/components/media-upload-outcome.ts +83 -0
  143. package/src/lib/content/compose.ts +3 -0
  144. package/src/lib/content/frontmatter.ts +16 -1
  145. package/src/lib/content/manifest.ts +44 -1
  146. package/src/lib/content/media-refs.ts +58 -0
  147. package/src/lib/content/schema.ts +31 -7
  148. package/src/lib/content/types.ts +78 -13
  149. package/src/lib/content/validate.ts +26 -1
  150. package/src/lib/delivery/public-routes.ts +52 -3
  151. package/src/lib/delivery/seo-fields.ts +6 -1
  152. package/src/lib/delivery/seo.ts +5 -0
  153. package/src/lib/doctor/checks-local.ts +22 -0
  154. package/src/lib/doctor/index.ts +21 -3
  155. package/src/lib/doctor/types.ts +3 -0
  156. package/src/lib/doctor/wrangler-config.ts +23 -0
  157. package/src/lib/env.ts +28 -0
  158. package/src/lib/index.ts +2 -0
  159. package/src/lib/log/events.ts +8 -1
  160. package/src/lib/media/config.ts +103 -0
  161. package/src/lib/media/delivery-bucket.ts +41 -0
  162. package/src/lib/media/index.ts +22 -0
  163. package/src/lib/media/library-entry.ts +58 -0
  164. package/src/lib/media/manifest.ts +122 -0
  165. package/src/lib/media/naming.ts +130 -0
  166. package/src/lib/media/reconcile.ts +79 -0
  167. package/src/lib/media/reference.ts +40 -0
  168. package/src/lib/media/sniff.ts +114 -0
  169. package/src/lib/media/store.ts +57 -0
  170. package/src/lib/media/transform-url.ts +58 -0
  171. package/src/lib/media/usage.ts +152 -0
  172. package/src/lib/render/component-grammar.ts +59 -3
  173. package/src/lib/render/component-validate.ts +22 -1
  174. package/src/lib/render/pipeline.ts +17 -3
  175. package/src/lib/render/registry.ts +38 -0
  176. package/src/lib/render/remark-figure.ts +132 -0
  177. package/src/lib/render/resolve-media.ts +96 -0
  178. package/src/lib/render/sanitize-schema.ts +5 -3
  179. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  180. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  181. package/src/lib/sveltekit/content-routes.ts +573 -12
  182. package/src/lib/sveltekit/csrf.ts +18 -0
  183. package/src/lib/sveltekit/guard.ts +12 -3
  184. package/src/lib/sveltekit/index.ts +6 -0
  185. package/src/lib/sveltekit/media-route.ts +158 -0
  186. package/src/lib/vite/index.ts +9 -2
@@ -0,0 +1,380 @@
1
+ // cairn-cms: the client-side ingest helper. Browser-only, behind the editor seam: the orchestration
2
+ // touches createImageBitmap, a <canvas>, and a lazy-loaded heic-to WASM decoder, so this module lives
3
+ // beside the other dynamically-imported editor modules (editor-folding, editor-highlight, editor-modes)
4
+ // and is never re-exported from a node-safe subpath. The heic-to import is dynamic, so its WASM only
5
+ // downloads when an author actually drops a HEIC; the static imports here are the pure node-safe
6
+ // sniff/slug helpers, which keep this module loadable in a unit test.
7
+ //
8
+ // The split: the pure parts (HEIC magic detection, GIF header parse, the slug-versus-proposed-name
9
+ // call, the DataTransfer normalizer, the canvas budget, the failure taxonomy, the upload request
10
+ // shape) are exported and unit-tested. The browser-coupled orchestration (ingestFile, the drop guard)
11
+ // is thin glue over them, wired here but proven live at Phase 2b and on a site, not in this suite.
12
+ //
13
+ // The client is untrusted. The server re-derives the type, the slug, the hash, and the size on every
14
+ // upload (the Task 5 uploadAction), so this helper exists for UX (a correct preview, no dead wait),
15
+ // never for security.
16
+ import { sniffMediaType } from '../media/sniff.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Pure exports (unit-tested below the seam)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * True when the leading bytes are a HEIC/HEIF `ftyp` box (the sniff returns `image/heic`). Driven by
24
+ * the magic bytes alone, never the filename extension or the browser-supplied MIME, since both lie on
25
+ * an iPhone share-sheet drop.
26
+ */
27
+ export function detectHeic(bytes: Uint8Array): boolean {
28
+ return sniffMediaType(bytes) === 'image/heic';
29
+ }
30
+
31
+ /**
32
+ * Read a GIF's pixel dimensions from its logical-screen-descriptor header (the 16-bit little-endian
33
+ * width and height at bytes 6..9), or null when the input is not a `GIF8` file or is too short to hold
34
+ * the descriptor. GIF passes through ingest untouched (never a canvas), so the animation survives, and
35
+ * its dimensions come from this header rather than a decode.
36
+ */
37
+ export function gifDimensions(bytes: Uint8Array): { width: number; height: number } | null {
38
+ // 'GIF8' is the shared prefix of the 87a and 89a versions; the descriptor needs at least 10 bytes.
39
+ if (bytes.length < 10) return null;
40
+ if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x38) return null;
41
+ const width = bytes[6] | (bytes[7] << 8);
42
+ const height = bytes[8] | (bytes[9] << 8);
43
+ return { width, height };
44
+ }
45
+
46
+ /** A bare run of digits, the camera-counter stem of `1234.png` and friends. */
47
+ const BARE_NUMBER = /^\d+$/;
48
+
49
+ /** The fixed generic stems a phone or OS hands a file: the capture card treats these as no name. */
50
+ const GENERIC_STEMS = new Set(['image', 'photo', 'untitled', 'unnamed', 'download']);
51
+
52
+ /**
53
+ * The camera and OS counter patterns. `IMG_1234`, `DSC_0001`, `DSCN0001`, `P1010001`, the iPhone
54
+ * `IMG_E1234` edited form, a `Screenshot ...` stem, and a Windows/Android `Screenshot_...` stem are all
55
+ * machine-assigned and carry no authorial meaning, so they yield no proposed name.
56
+ */
57
+ const GENERIC_PATTERNS: RegExp[] = [
58
+ /^img[_-]?e?\d+$/i,
59
+ /^dsc[fn]?[_-]?\d+$/i,
60
+ /^p\d{7}$/i,
61
+ /^screenshot[ _-]/i,
62
+ /^screen shot /i,
63
+ ];
64
+
65
+ /**
66
+ * The slug-versus-proposed-name split. A real, specific filename stem yields a proposed display name
67
+ * (the stem, extension dropped) that the capture card pre-fills and tags Suggested. A generic
68
+ * machine-assigned stem (`IMG_1234`, `DSC_0001`, `image`, `photo`, `untitled`, a bare number, a HEIC
69
+ * counter, a screenshot pattern) yields null, and the card leaves the name field empty and required
70
+ * with no Suggested tag, since pre-filling `IMG_4821` would only train the author to accept noise.
71
+ */
72
+ export function proposedNameFor(filename: string): string | null {
73
+ const dot = filename.lastIndexOf('.');
74
+ const stem = (dot === -1 ? filename : filename.slice(0, dot)).trim();
75
+ if (stem === '') return null;
76
+
77
+ const lower = stem.toLowerCase();
78
+ if (GENERIC_STEMS.has(lower)) return null;
79
+ if (BARE_NUMBER.test(stem)) return null;
80
+ for (const pattern of GENERIC_PATTERNS) {
81
+ if (pattern.test(stem)) return null;
82
+ }
83
+ return stem;
84
+ }
85
+
86
+ /**
87
+ * The image files in a drag-and-drop payload, in order, dropping every non-image. Filters by
88
+ * `type.startsWith('image/')`, so a `text/uri-list` or `text/html` item dragged in from a browser tab
89
+ * is ignored (those arrive as `items`, never as image `files`). Testable with a plain object mock, so
90
+ * it never requires a real `DataTransfer`.
91
+ */
92
+ export function normalizeDataTransfer(dt: {
93
+ files?: ArrayLike<File>;
94
+ items?: ArrayLike<DataTransferItem>;
95
+ }): File[] {
96
+ const files = dt.files;
97
+ if (!files) return [];
98
+ const out: File[] = [];
99
+ for (let i = 0; i < files.length; i++) {
100
+ const file = files[i];
101
+ if (file && typeof file.type === 'string' && file.type.startsWith('image/')) out.push(file);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * The paste/drop routing decision as a pure call: the first image File a DataTransfer-shaped object
108
+ * carries, or null when it carries none. The editor's paste and drop handlers route on this, handing
109
+ * the file to ingest when it is non-null and falling through to CodeMirror's default when it is null
110
+ * (a plain-text or markdown paste). 2b is single-file per gesture (open risk 3), so this returns one
111
+ * file, never the rest. A thin wrapper over normalizeDataTransfer, kept pure so the decision is unit
112
+ * tested without a real DataTransfer.
113
+ */
114
+ export function firstImageFile(dt: {
115
+ files?: ArrayLike<File>;
116
+ items?: ArrayLike<DataTransferItem>;
117
+ }): File | null {
118
+ return normalizeDataTransfer(dt)[0] ?? null;
119
+ }
120
+
121
+ /** The conservative canvas area budget, about 16.7M px (4096 x 4096). A source over this is scaled
122
+ * down before any `drawImage`, never clipped. */
123
+ export const MAX_AREA = 16_777_216;
124
+
125
+ /** The conservative short-side budget. A source whose smaller dimension exceeds this is scaled down so
126
+ * the short side lands at the cap, even when its area is within MAX_AREA. */
127
+ export const MAX_SHORT_SIDE = 4096;
128
+
129
+ /**
130
+ * The canvas budget for a source of the given dimensions. When the source area exceeds MAX_AREA or its
131
+ * short side exceeds MAX_SHORT_SIDE, scale the whole image down proportionally so both bounds hold
132
+ * (aspect ratio preserved, never clipped, never upscaled); otherwise return the input unchanged. Pure
133
+ * math, so the budget is testable without a canvas.
134
+ */
135
+ export function budgetForDimensions(
136
+ width: number,
137
+ height: number,
138
+ ): { width: number; height: number } {
139
+ const area = width * height;
140
+ const shortSide = Math.min(width, height);
141
+ if (area <= MAX_AREA && shortSide <= MAX_SHORT_SIDE) return { width, height };
142
+
143
+ // One scale factor satisfies both bounds: the smaller of the area-fit and short-side-fit factors,
144
+ // capped at 1 so an already-small dimension is never upscaled.
145
+ const areaScale = Math.sqrt(MAX_AREA / area);
146
+ const shortScale = MAX_SHORT_SIDE / shortSide;
147
+ const scale = Math.min(areaScale, shortScale, 1);
148
+ return {
149
+ width: Math.max(1, Math.floor(width * scale)),
150
+ height: Math.max(1, Math.floor(height * scale)),
151
+ };
152
+ }
153
+
154
+ /** The ingest failure taxonomy. `decode-unsupported` is a format the browser and the HEIC decoder both
155
+ * refuse; `transcode-failed` is a HEIC decode or a canvas re-encode that threw; `too-large` is a
156
+ * source still over budget after a transcode; `network` is the upload fetch rejecting. */
157
+ export type IngestFailureKind =
158
+ | 'decode-unsupported'
159
+ | 'transcode-failed'
160
+ | 'too-large'
161
+ | 'network';
162
+
163
+ /** A failed ingest card: a stable discriminant plus a human message the capture card renders. */
164
+ export interface IngestFailureCard {
165
+ status: 'failed';
166
+ kind: IngestFailureKind;
167
+ message: string;
168
+ }
169
+
170
+ /** A pending ingest card, shown while the bytes are decoded, transcoded, and uploaded. */
171
+ export interface IngestPendingCard {
172
+ status: 'pending';
173
+ }
174
+
175
+ /** A ready ingest card: the upload succeeded and the server returned a reference. */
176
+ export interface IngestReadyCard {
177
+ status: 'ready';
178
+ reference: string;
179
+ width: number | null;
180
+ height: number | null;
181
+ }
182
+
183
+ /** The card states the capture UI moves through for a single ingested file. */
184
+ export type IngestCard = IngestPendingCard | IngestReadyCard | IngestFailureCard;
185
+
186
+ /** The human message per failure kind, plain and specific so an author knows whether to retry. */
187
+ const FAILURE_MESSAGE: Record<IngestFailureKind, string> = {
188
+ 'decode-unsupported': 'This image format is not supported. Try a JPEG, PNG, WebP, or GIF.',
189
+ 'transcode-failed': 'This image could not be converted. Try exporting it as a JPEG first.',
190
+ 'too-large': 'This image is too large to add, even after shrinking it.',
191
+ network: 'The upload could not reach the server. Check your connection and try again.',
192
+ };
193
+
194
+ /** Map a failure kind to its typed card state. */
195
+ export function failureCard(kind: IngestFailureKind): IngestFailureCard {
196
+ return { status: 'failed', kind, message: FAILURE_MESSAGE[kind] };
197
+ }
198
+
199
+ /** The fields the upload request carries, mirroring exactly what the Task 5 uploadAction reads back. */
200
+ export interface UploadRequestOpts {
201
+ conceptId: string;
202
+ id: string;
203
+ bytes: Uint8Array | Blob;
204
+ contentType: string;
205
+ csrf: string;
206
+ filename: string;
207
+ alt?: string;
208
+ displayName?: string;
209
+ width?: number | null;
210
+ height?: number | null;
211
+ }
212
+
213
+ /**
214
+ * Construct the upload request the helper will `fetch`: a `POST` to the named SvelteKit form action
215
+ * `/admin/<conceptId>/<id>?/upload`, the raw bytes as the body, `redirect: 'manual'` so the guard's
216
+ * expired-session 303 surfaces as an opaque, status-0 response (which the caller detects by
217
+ * `response.type === 'opaqueredirect'`) instead of being silently followed, and the `X-Cairn-*`
218
+ * headers the
219
+ * uploadAction reads. The filename, alt, and display name are percent-encoded so a unicode value
220
+ * survives header transport; width and height ride only when present. Pure: it returns the request
221
+ * shape and never calls `fetch`, so a test can assert the URL, method, and headers.
222
+ */
223
+ export function buildUploadRequest(opts: UploadRequestOpts): { url: string; init: RequestInit } {
224
+ const url = `/admin/${opts.conceptId}/${opts.id}?/upload`;
225
+ // The body rides as text/plain, not the image's real type. The upload is a SvelteKit form action
226
+ // (?/upload), and SvelteKit 415s a POST whose content type is not form-encoded before the action
227
+ // runs; text/plain is the one form content type that carries raw bytes without field encoding. The
228
+ // server ignores this label and sniffs the real type from the bytes. The CSRF token rides the
229
+ // X-Cairn-CSRF header so the guard clears the request without cloning the body.
230
+ const headers: Record<string, string> = {
231
+ 'X-Cairn-CSRF': opts.csrf,
232
+ 'Content-Type': 'text/plain',
233
+ 'X-Cairn-Filename': encodeURIComponent(opts.filename),
234
+ };
235
+ if (opts.alt !== undefined && opts.alt !== '') headers['X-Cairn-Alt'] = encodeURIComponent(opts.alt);
236
+ if (opts.displayName !== undefined && opts.displayName !== '') {
237
+ headers['X-Cairn-Display-Name'] = encodeURIComponent(opts.displayName);
238
+ }
239
+ if (opts.width !== undefined && opts.width !== null) headers['X-Cairn-Width'] = String(opts.width);
240
+ if (opts.height !== undefined && opts.height !== null) headers['X-Cairn-Height'] = String(opts.height);
241
+
242
+ // BodyInit accepts a Blob or a BufferSource; a Uint8Array is a BufferSource. The cast keeps the
243
+ // public BodyInit type while letting either input through.
244
+ const init: RequestInit = {
245
+ method: 'POST',
246
+ redirect: 'manual',
247
+ headers,
248
+ body: opts.bytes as BodyInit,
249
+ };
250
+ return { url, init };
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Browser-coupled glue (wired, proven live at 2b/site, not unit-tested here)
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /** The structural shape of the heic-to module's `heicTo`, typed here so the dynamic import stays
258
+ * lazy. A HEIC blob in, a decoded image blob out (PNG, with the HEIF orientation already applied). */
259
+ type HeicTo = (args: { blob: Blob; type: 'image/png' }) => Promise<Blob>;
260
+
261
+ /** A decoded source plus its dimensions, the input to the upload step. */
262
+ interface IngestResult {
263
+ blob: Blob;
264
+ contentType: string;
265
+ width: number | null;
266
+ height: number | null;
267
+ }
268
+
269
+ /** Thrown inside the orchestration so a kind maps cleanly to a failure card at the boundary. */
270
+ class IngestError extends Error {
271
+ constructor(readonly kind: IngestFailureKind) {
272
+ super(kind);
273
+ }
274
+ }
275
+
276
+ /**
277
+ * The three-tier ingest route for one file. Web-native types (JPEG, PNG, WebP) pass through with
278
+ * dimensions from `createImageBitmap`; GIF passes through with dimensions from `gifDimensions` (no
279
+ * canvas, so the animation survives); HEIC routes through the lazy-loaded heic-to decoder, is decoded
280
+ * to a PNG, then re-encoded to WebP through the canvas budget. Any forced re-encode targets WebP. This
281
+ * is thin glue over the pure helpers above and is proven live, not in this suite.
282
+ */
283
+ export async function ingestFile(file: File): Promise<IngestResult> {
284
+ const bytes = new Uint8Array(await file.arrayBuffer());
285
+ const sniffed = sniffMediaType(bytes);
286
+
287
+ if (sniffed === 'image/gif') {
288
+ // GIF passes through untouched: dimensions from the header, never a canvas.
289
+ const dims = gifDimensions(bytes);
290
+ return { blob: file, contentType: 'image/gif', width: dims?.width ?? null, height: dims?.height ?? null };
291
+ }
292
+
293
+ if (sniffed === 'image/jpeg' || sniffed === 'image/png' || sniffed === 'image/webp') {
294
+ // Web-native passthrough: read the real pixel dimensions with the embedded orientation applied.
295
+ let bitmap: ImageBitmap;
296
+ try {
297
+ bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
298
+ } catch {
299
+ throw new IngestError('decode-unsupported');
300
+ }
301
+ const out = { blob: file, contentType: sniffed, width: bitmap.width, height: bitmap.height };
302
+ bitmap.close();
303
+ return out;
304
+ }
305
+
306
+ if (detectHeic(bytes)) {
307
+ // HEIC: lazy-load the decoder so its WASM downloads only on a real HEIC drop. heicTo applies the
308
+ // HEIF orientation when it produces the PNG.
309
+ let png: Blob;
310
+ try {
311
+ const mod = (await import('heic-to')) as { heicTo: HeicTo };
312
+ png = await mod.heicTo({ blob: file, type: 'image/png' });
313
+ } catch {
314
+ throw new IngestError('transcode-failed');
315
+ }
316
+ return reencodeToWebp(png);
317
+ }
318
+
319
+ throw new IngestError('decode-unsupported');
320
+ }
321
+
322
+ /**
323
+ * Re-encode a decoded blob to WebP through the conservative canvas budget: read its dimensions, size a
324
+ * canvas to `budgetForDimensions` (scaling down, never clipping), draw the bitmap into it, and export
325
+ * WebP. A source still over budget after the transcode is a `too-large` failure rather than a clip.
326
+ */
327
+ async function reencodeToWebp(source: Blob): Promise<IngestResult> {
328
+ let bitmap: ImageBitmap;
329
+ try {
330
+ bitmap = await createImageBitmap(source, { imageOrientation: 'from-image' });
331
+ } catch {
332
+ throw new IngestError('transcode-failed');
333
+ }
334
+
335
+ const budget = budgetForDimensions(bitmap.width, bitmap.height);
336
+ if (budget.width * budget.height > MAX_AREA) {
337
+ bitmap.close();
338
+ throw new IngestError('too-large');
339
+ }
340
+
341
+ const canvas = document.createElement('canvas');
342
+ canvas.width = budget.width;
343
+ canvas.height = budget.height;
344
+ const ctx = canvas.getContext('2d');
345
+ if (!ctx) {
346
+ bitmap.close();
347
+ throw new IngestError('transcode-failed');
348
+ }
349
+ ctx.drawImage(bitmap, 0, 0, budget.width, budget.height);
350
+ bitmap.close();
351
+
352
+ const webp = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp'));
353
+ if (!webp) throw new IngestError('transcode-failed');
354
+ return { blob: webp, contentType: 'image/webp', width: budget.width, height: budget.height };
355
+ }
356
+
357
+ /** The failure kind for a thrown ingest error, defaulting an unknown throw to a decode failure. */
358
+ export function ingestFailureKind(error: unknown): IngestFailureKind {
359
+ return error instanceof IngestError ? error.kind : 'decode-unsupported';
360
+ }
361
+
362
+ /**
363
+ * Send a built upload request and return its raw `Response`, mapping a fetch rejection (the network
364
+ * down, the request aborted) to a `network` IngestError. The caller reads the SvelteKit action result
365
+ * from the response; this thin wrapper exists only so the tests can build a request without invoking
366
+ * `fetch`.
367
+ */
368
+ export async function sendUpload(url: string, init: RequestInit): Promise<Response> {
369
+ try {
370
+ return await fetch(url, init);
371
+ } catch {
372
+ throw new IngestError('network');
373
+ }
374
+ }
375
+
376
+ /** Guard a drop target: cancel the browser's default open-the-file behavior on `dragover` and `drop`
377
+ * so a dropped image stays inside the editor rather than navigating the page to the file. */
378
+ export function guardDropTarget(event: DragEvent): void {
379
+ event.preventDefault();
380
+ }
@@ -0,0 +1,248 @@
1
+ // The media: source decoration. Each `![alt](media:slug.hash)` token in the source has its URL part
2
+ // (the media: reference inside the parens, never the alt) replaced by an inline chip showing the
3
+ // asset's thumbnail and display name, with a persistent needs-alt marker when the alt is empty. The
4
+ // reference token is also made atomic, so a stray keystroke selects or replaces the whole reference
5
+ // rather than corrupting a hex digit.
6
+ //
7
+ // Client-only like editor-highlight, editor-modes, and editor-folding: MarkdownEditor reaches this
8
+ // module through a dynamic import, so the static @codemirror imports here never enter a server bundle
9
+ // (guarded by the editor-boundary test, whose DYNAMIC_ONLY list names this file).
10
+ //
11
+ // The library is reactive: MarkdownEditor feeds it in through a compartment reconfigured on a prop
12
+ // change, so a just-uploaded image decorates the moment it lands in the library. The chip reads the
13
+ // library for the thumbnail src, the display name, and the dimensions; a hash absent from the library
14
+ // renders a neutral fallback chip named from the token slug rather than throwing.
15
+ import {
16
+ Decoration,
17
+ EditorView,
18
+ ViewPlugin,
19
+ WidgetType,
20
+ type DecorationSet,
21
+ type ViewUpdate,
22
+ } from '@codemirror/view';
23
+ import { RangeSetBuilder, type Extension, type Range } from '@codemirror/state';
24
+ import { parseMediaToken } from '../media/reference.js';
25
+ import { publicPath } from '../media/naming.js';
26
+ import { fenceScan, figureRoleAtLine } from './markdown-directives.js';
27
+ // The decoration reads MediaLibrary/MediaLibraryEntry (the shared node-safe projection) for the
28
+ // thumbnail path, the display name, and the alt-empty test.
29
+ import type { MediaLibrary, MediaLibraryEntry } from '../media/library-entry.js';
30
+
31
+ // Markdown image tokens whose URL is a media: reference. The capture groups split the alt (group 1,
32
+ // author content that stays editable) from the URL token (group 2, the media: reference that becomes
33
+ // the atomic chip). The alt body excludes a closing bracket so a following `![...]` cannot run into
34
+ // it; the URL body excludes whitespace and the closing paren. parseMediaToken does the real
35
+ // validation, so a non-media or malformed URL is dropped after the match.
36
+ const MEDIA_IMAGE = /!\[([^\]]*)\]\((media:[^\s)]+)\)/g;
37
+
38
+ /** A matched media image in a line: the alt text and the URL token's character offsets within the
39
+ * whole document, plus the parsed reference and the library entry (null when the hash is unknown).
40
+ * figureRole carries the enclosing `:::figure` placement (the closed-set role, or `'figure'` for
41
+ * the measure default), or null when the token is not in a figure: a bare token shows no role pill. */
42
+ interface MediaImageMatch {
43
+ alt: string;
44
+ from: number;
45
+ to: number;
46
+ token: string;
47
+ slug: string | null;
48
+ hash: string;
49
+ entry: MediaLibraryEntry | null;
50
+ figureRole: 'center' | 'wide' | 'full' | 'figure' | null;
51
+ }
52
+
53
+ // The chip widget: an inline element carrying the thumbnail and the display name, with a needs-alt
54
+ // marker (a glyph plus a label, never hue alone) when the alt is empty. A library miss renders a
55
+ // neutral fallback chip named from the token slug. The widget never throws on a missing entry.
56
+ class MediaChipWidget extends WidgetType {
57
+ constructor(readonly match: MediaImageMatch) {
58
+ super();
59
+ }
60
+
61
+ eq(other: WidgetType): boolean {
62
+ if (!(other instanceof MediaChipWidget)) return false;
63
+ const a = this.match;
64
+ const b = other.match;
65
+ // The chip's appearance depends on the token, the alt-empty state, and the resolved entry's
66
+ // identity (its slug/ext/hash drive the thumbnail src and the name). Comparing those keeps the
67
+ // widget stable across a rebuild that did not change the chip.
68
+ return (
69
+ a.token === b.token &&
70
+ a.alt === b.alt &&
71
+ a.figureRole === b.figureRole &&
72
+ a.entry?.slug === b.entry?.slug &&
73
+ a.entry?.ext === b.entry?.ext &&
74
+ a.entry?.displayName === b.entry?.displayName
75
+ );
76
+ }
77
+
78
+ toDOM(): HTMLElement {
79
+ const { entry, slug, hash, alt, figureRole } = this.match;
80
+ const chip = document.createElement('span');
81
+ chip.className = 'cm-cairn-media-chip';
82
+ chip.setAttribute('aria-hidden', 'true');
83
+
84
+ if (entry) {
85
+ const img = document.createElement('img');
86
+ img.className = 'cm-cairn-media-thumb';
87
+ img.src = publicPath(entry.slug, entry.hash, entry.ext, 'slug');
88
+ img.alt = '';
89
+ img.setAttribute('aria-hidden', 'true');
90
+ chip.appendChild(img);
91
+ }
92
+
93
+ const name = document.createElement('span');
94
+ name.className = 'cm-cairn-media-name';
95
+ // The display name from the library, or the token slug as a neutral fallback when the hash is
96
+ // unknown to this entry's library (an image referenced from a branch whose manifest the read
97
+ // missed); the bare hash stands in when even the slug is absent.
98
+ name.textContent = entry?.displayName || slug || hash;
99
+ chip.appendChild(name);
100
+
101
+ if (figureRole !== null) {
102
+ // The figure/role pill: the role name (or "figure" for the measure default), in the directive
103
+ // accent language. It is present only inside a :::figure, so the visible chip and the source
104
+ // agree (the no-hidden-state rule). aria-hidden like the rest of the chip; the source `{.wide}`
105
+ // carries the meaning for assistive tech.
106
+ const pill = document.createElement('span');
107
+ pill.className = 'cm-cairn-media-role';
108
+ pill.setAttribute('aria-hidden', 'true');
109
+ pill.textContent = figureRole;
110
+ chip.appendChild(pill);
111
+ }
112
+
113
+ if (alt.trim() === '') {
114
+ // The needs-alt marker: a glyph and a label, never hue alone (the spec accessibility rule). The
115
+ // title gives the same words on hover, and the chip's name span already conveys which image.
116
+ const flag = document.createElement('span');
117
+ flag.className = 'cm-cairn-media-needs-alt';
118
+ flag.title = 'This image has no alt text. Add a description for screen readers.';
119
+ // A small warning glyph ahead of the label, marked decorative so the label carries the meaning.
120
+ const glyph = document.createElement('span');
121
+ glyph.className = 'cm-cairn-media-needs-alt-glyph';
122
+ glyph.setAttribute('aria-hidden', 'true');
123
+ glyph.textContent = '⚠'; // warning sign
124
+ const label = document.createElement('span');
125
+ label.textContent = 'Needs alt';
126
+ flag.appendChild(glyph);
127
+ flag.appendChild(label);
128
+ chip.appendChild(flag);
129
+ }
130
+
131
+ return chip;
132
+ }
133
+
134
+ // The chip carries its own interactive intent only through the surrounding atomic range; clicks on
135
+ // it should fall through to CodeMirror so the caret lands beside the atomic token.
136
+ ignoreEvent(): boolean {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ /** Scan one line's text for media image tokens, mapping each to its document offsets and resolving its
142
+ * library entry. lineFrom is the line's document start, so the match offsets become absolute. */
143
+ function matchesInLine(text: string, lineFrom: number, library: MediaLibrary): MediaImageMatch[] {
144
+ const out: MediaImageMatch[] = [];
145
+ MEDIA_IMAGE.lastIndex = 0;
146
+ let m: RegExpExecArray | null;
147
+ while ((m = MEDIA_IMAGE.exec(text)) !== null) {
148
+ const alt = m[1] ?? '';
149
+ const token = m[2] ?? '';
150
+ const ref = parseMediaToken(token);
151
+ if (!ref) continue; // a media:-prefixed but malformed token is left as plain source
152
+ // The URL token sits between the '(' after the alt's ']' and the closing ')'. The match starts
153
+ // at the '!', and the token's offset within the match is the index of the '(' plus one.
154
+ const tokenStart = m.index + m[0].indexOf('(' + token + ')') + 1;
155
+ const from = lineFrom + tokenStart;
156
+ const to = from + token.length;
157
+ out.push({
158
+ alt,
159
+ from,
160
+ to,
161
+ token,
162
+ slug: ref.slug,
163
+ hash: ref.hash,
164
+ entry: library[ref.hash] ?? null,
165
+ figureRole: null,
166
+ });
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** Every media image match across the editor's visible ranges, in document order, each carrying its
172
+ * enclosing figure role. One {@link fenceScan} over the whole document feeds the cheap per-token
173
+ * figure detection (no remark parse on the per-rebuild chip path); the visible lines are scanned
174
+ * for tokens, then each token's line index drives {@link figureRoleAtLine}. */
175
+ function visibleMatches(view: EditorView, library: MediaLibrary): MediaImageMatch[] {
176
+ const lines = view.state.doc.toString().split('\n');
177
+ const scan = fenceScan(lines);
178
+ const out: MediaImageMatch[] = [];
179
+ for (const { from, to } of view.visibleRanges) {
180
+ for (let pos = from; pos <= to; ) {
181
+ const line = view.state.doc.lineAt(pos);
182
+ const role = figureRoleAtLine(scan, lines, line.number - 1);
183
+ for (const match of matchesInLine(line.text, line.from, library)) {
184
+ out.push({ ...match, figureRole: role });
185
+ }
186
+ pos = line.to + 1;
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+
192
+ /** Replace decorations for each visible media image's reference token: the chip widget over the URL
193
+ * token, the alt left untouched. The same spans seed the atomic-range set. */
194
+ function buildMediaDecorations(view: EditorView, library: MediaLibrary): DecorationSet {
195
+ const builder = new RangeSetBuilder<Decoration>();
196
+ for (const match of visibleMatches(view, library)) {
197
+ builder.add(match.from, match.to, Decoration.replace({ widget: new MediaChipWidget(match) }));
198
+ }
199
+ return builder.finish();
200
+ }
201
+
202
+ /** The atomic ranges for the visible media reference tokens: a caret or selection edit treats each
203
+ * token as one unit, so a stray keystroke replaces the whole reference rather than corrupting a hex
204
+ * digit. Built from the same matches the decorations use, so the two never disagree. */
205
+ function buildAtomicRanges(view: EditorView, library: MediaLibrary): DecorationSet {
206
+ const ranges: Range<Decoration>[] = [];
207
+ for (const match of visibleMatches(view, library)) {
208
+ ranges.push(Decoration.replace({}).range(match.from, match.to));
209
+ }
210
+ return Decoration.set(ranges, true);
211
+ }
212
+
213
+ /**
214
+ * The media: source decoration extension over a projected library. Each `![alt](media:slug.hash)`
215
+ * token in the source shows the asset's thumbnail and display name in place of the reference, the
216
+ * reference is atomic, and an empty alt carries a persistent needs-alt marker. The library is the
217
+ * `mediaLibrary` projection EditData carries; MarkdownEditor holds it in a compartment and rebuilds
218
+ * this extension when the library changes, so a just-uploaded image decorates once it is in the
219
+ * library. An empty library is valid: nothing decorates.
220
+ */
221
+ export function cairnMediaDecorations(library: MediaLibrary): Extension {
222
+ const plugin = ViewPlugin.fromClass(
223
+ class {
224
+ decorations: DecorationSet;
225
+ atomic: DecorationSet;
226
+ constructor(view: EditorView) {
227
+ this.decorations = buildMediaDecorations(view, library);
228
+ this.atomic = buildAtomicRanges(view, library);
229
+ }
230
+ update(update: ViewUpdate) {
231
+ // A doc edit changes the tokens; a viewport change brings new lines into view. A caret move
232
+ // alone changes neither, so it is not a rebuild trigger here.
233
+ if (update.docChanged || update.viewportChanged) {
234
+ this.decorations = buildMediaDecorations(update.view, library);
235
+ this.atomic = buildAtomicRanges(update.view, library);
236
+ }
237
+ }
238
+ },
239
+ {
240
+ decorations: (v) => v.decorations,
241
+ // atomicRanges reads the plugin's own atomic set, so the unit the caret skips matches the
242
+ // decorated tokens exactly.
243
+ provide: (plugin) =>
244
+ EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomic ?? Decoration.none),
245
+ },
246
+ );
247
+ return plugin;
248
+ }