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