@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,12 @@
1
+ import type { RequestHandler } from '@sveltejs/kit';
2
+ import type { ResolvedAssetConfig } from '../media/config.js';
3
+ /**
4
+ * Build the media delivery `RequestHandler` for a site's resolved media config.
5
+ *
6
+ * The handler validates the hash and extension before any R2 call, derives the object key from the
7
+ * validated values only (never trusting the URL's fan-out), guards the Cloudflare Images self-loop,
8
+ * and sets the security headers on every served response.
9
+ *
10
+ * @param resolved the adapter's resolved media config; when media is off the handler always 404s.
11
+ */
12
+ export declare function createMediaRoute(resolved: ResolvedAssetConfig): RequestHandler;
@@ -0,0 +1,137 @@
1
+ import { requireBucket } from '../env.js';
2
+ import { CairnError } from '../diagnostics/index.js';
3
+ import { r2Key } from '../media/naming.js';
4
+ import { log } from '../log/index.js';
5
+ /** A 16-character lowercase hex content-hash prefix, validated before any R2 lookup. */
6
+ const HASH_RE = /^[0-9a-f]{16}$/;
7
+ /** The closed delivery extension allow-list. A filename ext outside this set is a 404 with no R2
8
+ * read, so the route can never serve a type it cannot vouch for. */
9
+ const DELIVERY_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif']);
10
+ /** The load-bearing XSS control: set on every non-404 response, so a served object can never run as
11
+ * active content. `Content-Type` comes from the stored, server-validated metadata via
12
+ * `writeHttpMetadata`; these override or add to it. */
13
+ function applySecurityHeaders(headers, etag) {
14
+ headers.set('X-Content-Type-Options', 'nosniff');
15
+ headers.set('Content-Disposition', 'inline');
16
+ headers.set('Content-Security-Policy', "default-src 'none'; sandbox");
17
+ headers.set('Cache-Control', 'public, max-age=31536000, immutable');
18
+ headers.set('ETag', etag);
19
+ // An object stored outside the upload pipeline (a manual put, a future import) may carry no
20
+ // content type, so writeHttpMetadata would set none. Pair `nosniff` with an explicit safe
21
+ // default rather than serving a typeless response.
22
+ if (!headers.has('Content-Type'))
23
+ headers.set('Content-Type', 'application/octet-stream');
24
+ }
25
+ /** True when the returned object carries a body (a full or ranged read), narrowing it to the body
26
+ * variant. R2 returns a body-less object on an `If-None-Match` hit. */
27
+ function hasBody(obj) {
28
+ return 'body' in obj && obj.body != null;
29
+ }
30
+ /**
31
+ * Build the media delivery `RequestHandler` for a site's resolved media config.
32
+ *
33
+ * The handler validates the hash and extension before any R2 call, derives the object key from the
34
+ * validated values only (never trusting the URL's fan-out), guards the Cloudflare Images self-loop,
35
+ * and sets the security headers on every served response.
36
+ *
37
+ * @param resolved the adapter's resolved media config; when media is off the handler always 404s.
38
+ */
39
+ export function createMediaRoute(resolved) {
40
+ return async (event) => {
41
+ // Media off: the route is mounted but serves nothing.
42
+ if (!resolved.enabled)
43
+ return new Response(null, { status: 404 });
44
+ // The catch-all param is conventionally `path` (route `/media/[...path]`). Decode each segment
45
+ // on its own and reject a traversal or an embedded slash, so no undecoded or `..` path reaches
46
+ // R2. params.path is `string | undefined` (kit's fallback ambient types), so guard the absence.
47
+ const raw = event.params.path;
48
+ if (typeof raw !== 'string' || raw === '')
49
+ return new Response(null, { status: 404 });
50
+ const segments = [];
51
+ for (const part of raw.split('/')) {
52
+ let decoded;
53
+ try {
54
+ decoded = decodeURIComponent(part);
55
+ }
56
+ catch {
57
+ return new Response(null, { status: 404 });
58
+ }
59
+ if (decoded === '' || decoded.includes('/') || decoded === '..') {
60
+ return new Response(null, { status: 404 });
61
+ }
62
+ segments.push(decoded);
63
+ }
64
+ // The filename is the last segment; its dot fields end with `<hash>.<ext>`. The slug, if any, is
65
+ // attacker-controlled and ignored. parseMediaToken does not run here.
66
+ const filename = segments[segments.length - 1];
67
+ const fields = filename.split('.');
68
+ if (fields.length < 2)
69
+ return new Response(null, { status: 404 });
70
+ const ext = fields[fields.length - 1].toLowerCase();
71
+ const hash = fields[fields.length - 2];
72
+ // Validate before any R2 call: a bad hash or ext is a 404 with no read.
73
+ if (!HASH_RE.test(hash) || !DELIVERY_EXTS.has(ext)) {
74
+ return new Response(null, { status: 404 });
75
+ }
76
+ // Derive the key from the validated values only; r2Key recomputes the fan-out from the hash.
77
+ const key = r2Key(hash, ext);
78
+ // Resolve the bucket. A missing binding is a drained 503 with a log, never a thrown 500.
79
+ let bucket;
80
+ try {
81
+ // `event.platform` is `App.Platform`, which the engine does not declare (a site does, with an
82
+ // `env`), so read it through a structural cast rather than naming the site's ambient type.
83
+ const platform = event.platform;
84
+ bucket = requireBucket(platform?.env ?? {}, resolved.bucketBinding);
85
+ }
86
+ catch (err) {
87
+ if (err instanceof CairnError && err.conditionId === 'config.bindings-missing') {
88
+ log.warn('media.delivery_failed', {
89
+ reason: 'binding-missing',
90
+ binding: resolved.bucketBinding,
91
+ });
92
+ return new Response(null, { status: 503 });
93
+ }
94
+ throw err;
95
+ }
96
+ // Self-loop guard: the Cloudflare Images origin subrequest carries `Via: image-resizing`. Serve
97
+ // it a clean full-body 200 with no conditional or range handling, so a transform cannot loop.
98
+ const via = event.request.headers.get('Via') ?? '';
99
+ const isImageResizing = via.includes('image-resizing');
100
+ // Only forward `range` when the request actually carried a `Range` header. R2 populates the
101
+ // returned object's `.range` whenever a `range` option is passed (even a header-less one), so
102
+ // passing it unconditionally would turn every full GET into a 206.
103
+ const hasRangeRequest = !isImageResizing && event.request.headers.has('Range');
104
+ const getOpts = isImageResizing
105
+ ? undefined
106
+ : {
107
+ onlyIf: event.request.headers,
108
+ ...(hasRangeRequest ? { range: event.request.headers } : {}),
109
+ };
110
+ const obj = await bucket.get(key, getOpts);
111
+ if (obj === null)
112
+ return new Response(null, { status: 404 });
113
+ // A body-less object is R2's `If-None-Match` hit: 304, no body. Skipped for the self-loop path,
114
+ // which always requested the full body.
115
+ if (!isImageResizing && !hasBody(obj)) {
116
+ const headers = new Headers();
117
+ obj.writeHttpMetadata(headers);
118
+ applySecurityHeaders(headers, obj.httpEtag);
119
+ return new Response(null, { status: 304, headers });
120
+ }
121
+ const headers = new Headers();
122
+ obj.writeHttpMetadata(headers);
123
+ applySecurityHeaders(headers, obj.httpEtag);
124
+ // A ranged read carries `obj.range`: respond 206 with a Content-Range. R2 fills the served
125
+ // window; derive the bounds defensively against the full size.
126
+ if (hasRangeRequest && obj.range) {
127
+ const start = obj.range.offset ?? 0;
128
+ const length = obj.range.length ?? obj.size - start;
129
+ const end = start + length - 1;
130
+ headers.set('Content-Range', `bytes ${start}-${end}/${obj.size}`);
131
+ const body = hasBody(obj) ? obj.body : null;
132
+ return new Response(body, { status: 206, headers });
133
+ }
134
+ const body = hasBody(obj) ? obj.body : null;
135
+ return new Response(body, { status: 200, headers });
136
+ };
137
+ }
@@ -40,6 +40,9 @@ export interface AdapterFacts {
40
40
  repo?: string;
41
41
  /** `cairn.sender.from`. */
42
42
  from?: string;
43
+ /** `cairn.assets.bucketBinding`, the media R2 binding name; undefined when the adapter declares no
44
+ * assets. The doctor's conditional media-bucket check reads it. */
45
+ mediaBucketBinding?: string;
43
46
  }
44
47
  /** Read `{ owner, repo, from }` off the consumer's adapter by evaluating a tiny virtual module
45
48
  * through the consumer's own Vite resolution, the same machinery the cairn-manifest bin uses.
@@ -181,17 +181,20 @@ function cairnVirtualOnly(source) {
181
181
  };
182
182
  }
183
183
  /** Build the virtual module that reads only the adapter facts the doctor derives. It imports the
184
- * configured config module and exports the string-typed `owner`, `repo`, and `from` as JSON, so
185
- * nothing else of the adapter (least of all a secret) crosses the boundary. */
184
+ * configured config module and exports the string-typed `owner`, `repo`, `from`, and the media
185
+ * `bucketBinding` as JSON, so nothing else of the adapter (least of all a secret) crosses the
186
+ * boundary. */
186
187
  function adapterFactsSource(opts) {
187
188
  return `
188
189
  import { cairn } from ${JSON.stringify(opts.configModule)};
189
190
  const backend = cairn?.backend ?? {};
190
191
  const sender = cairn?.sender ?? {};
192
+ const assets = cairn?.assets ?? {};
191
193
  const facts = {};
192
194
  if (typeof backend.owner === 'string') facts.owner = backend.owner;
193
195
  if (typeof backend.repo === 'string') facts.repo = backend.repo;
194
196
  if (typeof sender.from === 'string') facts.from = sender.from;
197
+ if (typeof assets.bucketBinding === 'string') facts.mediaBucketBinding = assets.bucketBinding;
195
198
  export const result = JSON.stringify(facts);
196
199
  `;
197
200
  }
@@ -218,6 +221,8 @@ export async function readAdapterFacts(cwd = process.cwd()) {
218
221
  facts.repo = parsed.repo;
219
222
  if (typeof parsed.from === 'string')
220
223
  facts.from = parsed.from;
224
+ if (typeof parsed.mediaBucketBinding === 'string')
225
+ facts.mediaBucketBinding = parsed.mediaBucketBinding;
221
226
  return facts;
222
227
  }
223
228
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.56.1",
3
+ "version": "0.57.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -32,6 +32,7 @@
32
32
  "check:reference:signatures": "npm run package && node scripts/check-reference-signatures.mjs",
33
33
  "check:readiness": "npm run package && node scripts/check-readiness.mjs",
34
34
  "check:docs": "node scripts/docs-links.mjs",
35
+ "check:version": "node scripts/check-version.mjs",
35
36
  "check:prose": "node scripts/check-admin-prose.mjs",
36
37
  "prepare": "npm run package",
37
38
  "check": "svelte-check --tsconfig ./tsconfig.json",
@@ -77,6 +78,11 @@
77
78
  "svelte": "./dist/delivery/data.js",
78
79
  "default": "./dist/delivery/data.js"
79
80
  },
81
+ "./media": {
82
+ "types": "./dist/media/index.d.ts",
83
+ "svelte": "./dist/media/index.js",
84
+ "default": "./dist/media/index.js"
85
+ },
80
86
  "./vite": {
81
87
  "types": "./dist/vite/index.d.ts",
82
88
  "default": "./dist/vite/index.js"
@@ -116,6 +122,7 @@
116
122
  "gray-matter": "^4",
117
123
  "hast-util-sanitize": "^5.0.2",
118
124
  "hastscript": "^9.0.1",
125
+ "heic-to": "^1.5.2",
119
126
  "mdast-util-directive": "^3.1.0",
120
127
  "rehype-raw": "^7.0.0",
121
128
  "rehype-sanitize": "^6.0.0",
@@ -20,6 +20,7 @@ identical on every host regardless of the site's own theme.
20
20
  import SignpostIcon from '@lucide/svelte/icons/signpost';
21
21
  import SettingsIcon from '@lucide/svelte/icons/settings';
22
22
  import UsersIcon from '@lucide/svelte/icons/users';
23
+ import ImageIcon from '@lucide/svelte/icons/image';
23
24
  import BlocksIcon from '@lucide/svelte/icons/blocks';
24
25
  import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
25
26
  import './cairn-admin.css';
@@ -56,6 +57,8 @@ identical on every host regardless of the site's own theme.
56
57
  // the owner-only Editors.
57
58
  const coreItems: NavItem[] = $derived([
58
59
  ...data.concepts.map((c) => ({ label: c.label, icon: FileTextIcon, href: `/admin/${c.id}` })),
60
+ // Media is a content peer, immediately after the concepts.
61
+ { label: 'Media', icon: ImageIcon, href: '/admin/media' },
59
62
  ...(data.navLabel ? [{ label: data.navLabel, icon: SignpostIcon, href: '/admin/nav' }] : []),
60
63
  { label: 'Settings', icon: SettingsIcon, href: '/admin/settings' },
61
64
  ...(data.canManageEditors ? [{ label: 'Editors', icon: UsersIcon, href: '/admin/editors' }] : []),
@@ -13,11 +13,13 @@ mount inside `AdminLayout`. No styling or wrapper elements of its own.
13
13
  import EditPage from './EditPage.svelte';
14
14
  import ManageEditors from './ManageEditors.svelte';
15
15
  import NavTree from './NavTree.svelte';
16
+ import CairnMediaLibrary from './CairnMediaLibrary.svelte';
16
17
  import type { AdminData } from '../sveltekit/cairn-admin.js';
17
18
  import type { ContentFormFailure } from '../sveltekit/content-routes.js';
18
19
  import type { ComponentRegistry } from '../render/registry.js';
19
20
  import type { IconSet } from '../render/glyph.js';
20
21
  import type { LinkResolve } from '../content/links.js';
22
+ import type { MediaResolve } from '../render/resolve-media.js';
21
23
 
22
24
  interface Props {
23
25
  /** The discriminated view data from `createCairnAdmin`'s load. */
@@ -33,7 +35,10 @@ mount inside `AdminLayout`. No styling or wrapper elements of its own.
33
35
  })
34
36
  | null;
35
37
  /** The site's design-accurate render pipeline, for the edit view's preview pane. */
36
- render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
38
+ render?: (
39
+ md: string,
40
+ opts?: { stagger?: boolean; resolve?: LinkResolve; resolveMedia?: MediaResolve },
41
+ ) => string | Promise<string>;
37
42
  /** The site's component registry, for the edit view's insert palette. */
38
43
  registry?: ComponentRegistry;
39
44
  /** The site's icon set, for the edit view's guided form fields. */
@@ -62,6 +67,8 @@ mount inside `AdminLayout`. No styling or wrapper elements of its own.
62
67
  <ManageEditors data={data.page} {form} />
63
68
  {:else if data.view === 'nav'}
64
69
  <NavTree data={data.page} />
70
+ {:else if data.view === 'media'}
71
+ <CairnMediaLibrary data={data.page} {form} />
65
72
  {/if}
66
73
  </AdminLayout>
67
74
  {/if}