@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
@@ -6,8 +6,10 @@
6
6
  import { unified } from 'unified';
7
7
  import remarkParse from 'remark-parse';
8
8
  import remarkGfm from 'remark-gfm';
9
+ import remarkDirective from 'remark-directive';
9
10
  import { visit } from 'unist-util-visit';
10
11
  import { escapeLinkText } from '../content/links.js';
12
+ import { parseMediaToken } from '../media/reference.js';
11
13
  const WRAP = { bold: '**', italic: '_', code: '`', strike: '~~' };
12
14
  /**
13
15
  * Per-kind line-prefix behavior. `prefix` builds the marker for the line's 0-based index (only ol
@@ -122,6 +124,47 @@ export function insertInlineLink(doc, from, to, href, title) {
122
124
  const end = from + inserted.length;
123
125
  return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: end, to: end };
124
126
  }
127
+ /**
128
+ * Insert an inline markdown image at the selection. The committed form is `![alt](ref)` where `ref`
129
+ * is the full `media:slug.hash` token. The alt is escaped the way an inline link's title is (the `[`
130
+ * and `]` an author types must not break the image syntax); a selection is replaced rather than
131
+ * wrapped, since an image carries no display text to wrap. The cursor collapses just after the
132
+ * inserted text, and no surrounding blank lines are added, since an image is inline. Pure, so the
133
+ * editor dispatches the result.
134
+ */
135
+ export function insertImage(doc, from, to, alt, ref) {
136
+ const inserted = `![${escapeLinkText(alt)}](${ref})`;
137
+ const end = from + inserted.length;
138
+ return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: end, to: end };
139
+ }
140
+ /**
141
+ * Scan a markdown body for media images that carry no alt text, the publish-time accessibility debt
142
+ * the edit page counts. The document is parsed with the same remark pipeline unwrapCairnLink uses,
143
+ * so the two agree on what an image is. Each `image` node whose url is a valid `media:` reference and
144
+ * whose alt is empty or whitespace-only is returned with its source offsets and parsed slug and hash.
145
+ * Parsing (not a raw regex) means a `![](media:x)` written inside a code span or fence is not an
146
+ * image node and is correctly ignored, as is an alt-bearing media image and any non-media image (an
147
+ * http or cairn: url). Pure and node-safe, so the edit page derives the live count without a browser.
148
+ * The bare `media:<hash>` form yields an empty slug.
149
+ */
150
+ export function findMediaImagesNeedingAlt(doc) {
151
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
152
+ const hits = [];
153
+ visit(tree, 'image', (node) => {
154
+ const ref = parseMediaToken(node.url);
155
+ if (!ref)
156
+ return;
157
+ if ((node.alt ?? '').trim() !== '')
158
+ return;
159
+ const from = node.position?.start?.offset;
160
+ const to = node.position?.end?.offset;
161
+ if (from == null || to == null)
162
+ return;
163
+ hits.push({ from, to, ref: node.url, slug: ref.slug ?? '', hash: ref.hash });
164
+ });
165
+ hits.sort((a, b) => a.from - b.from);
166
+ return hits;
167
+ }
125
168
  /** Concatenate a link node's text-child values. The parser has already unescaped them, so a source
126
169
  * `Notes \[draft\]` yields `Notes [draft]`. Used instead of mdast-util-to-string, which is not a
127
170
  * direct dependency. Non-text children (a nested emphasis, say) contribute no value, which is fine
@@ -157,3 +200,215 @@ export function unwrapCairnLink(doc, href) {
157
200
  }
158
201
  return out;
159
202
  }
203
+ const FIGURE_ROLES = new Set(['center', 'wide', 'full']);
204
+ /** Parse a doc with the figure-aware pipeline (the render step's grammar), so the editor transforms
205
+ * agree with what renders. Container directives need remark-directive on top of the markdown base. */
206
+ function parseFigureDoc(doc) {
207
+ return unified().use(remarkParse).use(remarkGfm).use(remarkDirective).parse(doc);
208
+ }
209
+ /** Find the media `image` node whose source range contains `pos`, or whose enclosing figure contains
210
+ * `pos`, along with its enclosing `figure` directive when there is one. Returns null when `pos` is
211
+ * not on a media image nor inside a figure that wraps one. */
212
+ function locateMediaImage(tree, pos) {
213
+ let bareHit = null;
214
+ let figureHit = null;
215
+ // Track the figure ancestor while visiting; unist-util-visit hands the ancestors array last.
216
+ visit(tree, 'image', (node, _index, _parent) => {
217
+ if (!parseMediaToken(node.url))
218
+ return;
219
+ const from = node.position?.start?.offset;
220
+ const to = node.position?.end?.offset;
221
+ if (from == null || to == null)
222
+ return;
223
+ const figure = enclosingFigure(tree, node);
224
+ if (pos >= from && pos <= to) {
225
+ if (figure)
226
+ figureHit = { image: node, figure };
227
+ else if (!bareHit)
228
+ bareHit = { image: node, figure: null };
229
+ return;
230
+ }
231
+ // The caret can sit in the caption, off the image token; a media image inside a figure whose
232
+ // block range contains pos still counts as "at" that figure.
233
+ if (figure) {
234
+ const f0 = figure.position?.start?.offset;
235
+ const f1 = figure.position?.end?.offset;
236
+ if (f0 != null && f1 != null && pos >= f0 && pos <= f1 && !figureHit) {
237
+ figureHit = { image: node, figure };
238
+ }
239
+ }
240
+ });
241
+ // A figure hit (the caret on the image or anywhere in its block) wins over a bare hit.
242
+ return figureHit ?? bareHit;
243
+ }
244
+ /** The `figure`-named container directive that encloses `node`, or null. Walks the tree to find the
245
+ * ancestor, since unist-util-visit's per-call ancestors are not retained across the traversal. */
246
+ function enclosingFigure(tree, target) {
247
+ let found = null;
248
+ visit(tree, 'containerDirective', (dir) => {
249
+ if (dir.name !== 'figure')
250
+ return;
251
+ let holds = false;
252
+ visit(dir, 'image', (img) => {
253
+ if (img === target)
254
+ holds = true;
255
+ });
256
+ if (holds)
257
+ found = dir;
258
+ });
259
+ return found;
260
+ }
261
+ /** Strip one leading backslash sitting immediately before a colon, the inverse of the fence-escape
262
+ * wrapImageInFigure/updateFigure apply, so a caption that began with a directive-opening colon run
263
+ * round-trips to the author's original text. */
264
+ function unescapeCaption(raw) {
265
+ return raw.replace(/^\\(?=:)/, '');
266
+ }
267
+ /** Collapse a raw caption source span to the single-line value the control edits: internal newlines
268
+ * to single spaces, trimmed, with the leading-colon fence escape stripped. */
269
+ function finishCaption(raw) {
270
+ return unescapeCaption(raw.replace(/\s*\n\s*/g, ' ').trim());
271
+ }
272
+ /** Read the raw caption source from a figure directive, mirroring the render step's caption: the first
273
+ * text-bearing content after the image. The render step (remark-figure.ts) handles both caption
274
+ * forms, so the read must too. In the no-blank-line form the caption shares the image's paragraph,
275
+ * trailing the token, so it is read from the token end to that block's end; in the blank-line form it
276
+ * is the first text-bearing block after the image's paragraph. Only the first such content is the
277
+ * caption (a later block is a stray paragraph the render leaves outside the figcaption). Empty when
278
+ * the figure has no caption. */
279
+ function readCaption(doc, figure, image) {
280
+ const imageStart = image.position?.start?.offset;
281
+ const imageEnd = image.position?.end?.offset;
282
+ if (imageStart == null || imageEnd == null)
283
+ return '';
284
+ // The figure's direct child that holds the image (its paragraph).
285
+ const imageBlock = figure.children.find((child) => {
286
+ const start = child.position?.start?.offset;
287
+ const end = child.position?.end?.offset;
288
+ return start != null && end != null && start <= imageStart && end >= imageEnd;
289
+ });
290
+ // No-blank-line form: caption text trails the token inside the image's own paragraph.
291
+ const blockEnd = imageBlock?.position?.end?.offset;
292
+ if (blockEnd != null && doc.slice(imageEnd, blockEnd).trim() !== '') {
293
+ return finishCaption(doc.slice(imageEnd, blockEnd));
294
+ }
295
+ // Blank-line form: the first text-bearing block after the image's paragraph.
296
+ const afterEnd = blockEnd ?? imageEnd;
297
+ for (const child of figure.children) {
298
+ const start = child.position?.start?.offset;
299
+ const end = child.position?.end?.offset;
300
+ if (start == null || end == null)
301
+ continue;
302
+ if (start < afterEnd)
303
+ continue;
304
+ if (!blockHasText(child))
305
+ continue;
306
+ return finishCaption(doc.slice(start, end));
307
+ }
308
+ return '';
309
+ }
310
+ /** Whether a block's subtree carries any non-whitespace text, the caption-candidate test the render
311
+ * step uses (a bare image paragraph has no text node, so it is never read as a caption). */
312
+ function blockHasText(node) {
313
+ let found = false;
314
+ visit(node, 'text', (text) => {
315
+ if (text.value.trim() !== '')
316
+ found = true;
317
+ });
318
+ return found;
319
+ }
320
+ /**
321
+ * Inspect the media image at caret position `pos`. Returns the image's exact token offsets plus the
322
+ * enclosing `:::figure` block (its range, raw caption, and role) when the image is wrapped, or
323
+ * `figure: null` when it is bare. Returns null when `pos` is not on or in a media image. The parse
324
+ * uses the figure-aware pipeline, so this agrees with what remarkFigure renders. Pure and node-safe.
325
+ */
326
+ export function figureAtImage(doc, pos) {
327
+ const tree = parseFigureDoc(doc);
328
+ const hit = locateMediaImage(tree, pos);
329
+ if (!hit)
330
+ return null;
331
+ const imageFrom = hit.image.position?.start?.offset;
332
+ const imageTo = hit.image.position?.end?.offset;
333
+ if (imageFrom == null || imageTo == null)
334
+ return null;
335
+ if (!hit.figure)
336
+ return { imageFrom, imageTo, figure: null };
337
+ const dir = hit.figure;
338
+ const from = dir.position?.start?.offset;
339
+ const to = dir.position?.end?.offset;
340
+ if (from == null || to == null)
341
+ return { imageFrom, imageTo, figure: null };
342
+ const className = dir.attributes?.class ?? undefined;
343
+ const role = className && FIGURE_ROLES.has(className) ? className : null;
344
+ return { imageFrom, imageTo, figure: { from, to, caption: readCaption(doc, dir, hit.image), role } };
345
+ }
346
+ /** Sanitize a caption into a single safe body line: collapse internal newlines to single spaces,
347
+ * trim, and neutralize ONLY the directive-fence hazard (a leading colon would open a directive at
348
+ * line start) by prefixing one backslash. The author's inline markdown is preserved otherwise, so
349
+ * emphasis and links survive. figureAtImage strips the backslash on read for a clean round-trip. */
350
+ function sanitizeCaption(caption) {
351
+ const line = caption.replace(/\s*\n\s*/g, ' ').trim();
352
+ return line.startsWith(':') ? '\\' + line : line;
353
+ }
354
+ /** Build the canonical figure block source: the opener (with the role brace only for a non-null
355
+ * role), the image token verbatim on its own line, then a blank line and the sanitized caption when
356
+ * the caption is non-empty, and the closing fence. This is the blank-line form remarkFigure reads as
357
+ * its primary path, and it reads cleanly when hand-edited. */
358
+ function buildFigureBlock(imageSrc, caption, role) {
359
+ const opener = role ? `:::figure{.${role}}` : ':::figure';
360
+ const cap = sanitizeCaption(caption);
361
+ const body = cap ? `${imageSrc}\n\n${cap}` : imageSrc;
362
+ return `${opener}\n${body}\n:::`;
363
+ }
364
+ /**
365
+ * Wrap a bare media image in a `:::figure` block. The image token is reused EXACTLY from its source
366
+ * offsets and never reserialized (open risk 3: the atomic `media:` reference stays byte-identical).
367
+ * The block lands on its own lines, with a blank line before it (unless it starts the document) and
368
+ * after it, so it reads as a clean block even when the image sat inline in a paragraph. The selection
369
+ * collapses just past the inserted block.
370
+ */
371
+ export function wrapImageInFigure(doc, imageFrom, imageTo, caption, role) {
372
+ const imageSrc = doc.slice(imageFrom, imageTo);
373
+ const block = buildFigureBlock(imageSrc, caption, role);
374
+ const before = doc.slice(0, imageFrom);
375
+ const after = doc.slice(imageTo);
376
+ // Ensure the block starts on its own line: a blank line before it unless it opens the doc or the
377
+ // text before it already ends with one. Trailing context gets a matching blank line.
378
+ const lead = before === '' ? '' : before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
379
+ const trail = after === '' ? '' : after.startsWith('\n\n') ? '' : after.startsWith('\n') ? '\n' : '\n\n';
380
+ const inserted = lead + block + trail;
381
+ const blockStart = imageFrom + lead.length;
382
+ const end = blockStart + block.length;
383
+ return { doc: before + inserted + after, from: end, to: end };
384
+ }
385
+ /** The inner image token of the figure at `figureRange.from`, sliced verbatim from the source so it
386
+ * is reused byte-for-byte (open risk 3). Empty when no media image is found there, which leaves the
387
+ * rebuild image-less rather than throwing. Shared by updateFigure and unwrapFigure. */
388
+ function figureImageSrc(doc, figureRange) {
389
+ const info = figureAtImage(doc, figureRange.from);
390
+ return info ? doc.slice(info.imageFrom, info.imageTo) : '';
391
+ }
392
+ /**
393
+ * Rewrite an existing figure's caption and role in place. The inner image token is extracted from the
394
+ * current block and PRESERVED BYTE-FOR-BYTE (open risk 3); the block is rebuilt in the blank-line
395
+ * form with the new opener and caption. The selection collapses just past the rewritten block.
396
+ */
397
+ export function updateFigure(doc, figureRange, caption, role) {
398
+ const block = buildFigureBlock(figureImageSrc(doc, figureRange), caption, role);
399
+ const end = figureRange.from + block.length;
400
+ return { doc: doc.slice(0, figureRange.from) + block + doc.slice(figureRange.to), from: end, to: end };
401
+ }
402
+ /**
403
+ * Unwrap a figure block back to its bare image line, dropping the caption and the directive fences.
404
+ * The inner image token is reused verbatim (open risk 3). The selection lands on the restored image
405
+ * so the author can act on it again.
406
+ */
407
+ export function unwrapFigure(doc, figureRange) {
408
+ const imageSrc = figureImageSrc(doc, figureRange);
409
+ return {
410
+ doc: doc.slice(0, figureRange.from) + imageSrc + doc.slice(figureRange.to),
411
+ from: figureRange.from,
412
+ to: figureRange.from + imageSrc.length,
413
+ };
414
+ }
@@ -0,0 +1,52 @@
1
+ import type { MediaEntry } from '../media/manifest.js';
2
+ import type { UploadResult } from '../sveltekit/content-routes.js';
3
+ import type { IngestFailureKind } from './client-ingest.js';
4
+ /** A failure the card surfaces. The ingest taxonomy plus a `generic` catch-all for a refuse reason
5
+ * with no specific author-facing card (a binding-missing, a length-required, a parse miss). */
6
+ export type UploadFailureKind = IngestFailureKind | 'generic';
7
+ /** The outcome the popover acts on. `inserted` swaps the placeholder for the reference and records
8
+ * the entry; `failed` cancels the placeholder and shows the typed card; `session-expired` cancels
9
+ * the placeholder and tells the author to sign in again. */
10
+ export type UploadOutcome = {
11
+ kind: 'inserted';
12
+ reference: string;
13
+ record: MediaEntry;
14
+ reused: boolean;
15
+ } | {
16
+ kind: 'failed';
17
+ failure: UploadFailureKind;
18
+ } | {
19
+ kind: 'session-expired';
20
+ };
21
+ /** The shape the popover hands in: either a parsed SvelteKit action result (success or failure) or a
22
+ * bare response signal for the redirect and network-error cases. The popover deserializes the body
23
+ * for the success and failure cases and passes the raw `response.type`/`response.status` for the
24
+ * redirect case, so this one mapper covers every branch. */
25
+ export type UploadEnvelope = {
26
+ type: 'success';
27
+ status?: number;
28
+ data: UploadResult;
29
+ } | {
30
+ type: 'failure';
31
+ status?: number;
32
+ data?: {
33
+ error?: string;
34
+ };
35
+ } | {
36
+ type: 'redirect';
37
+ status?: number;
38
+ } | {
39
+ type: 'error';
40
+ status?: number;
41
+ } | {
42
+ type: 'opaqueredirect';
43
+ status?: number;
44
+ };
45
+ /**
46
+ * Map a parsed upload envelope to the single outcome the popover acts on. A success envelope yields
47
+ * an `inserted` outcome carrying the reference, the record, and the dedup flag. A failure envelope
48
+ * maps its refuse reason to a typed card, with `session-expired` lifted to its own outcome. An
49
+ * opaque or status-0 response (the guard's `redirect: 'manual'` 303) is a session-expired signal, as
50
+ * is any redirect-typed result. An error-typed result with a real status is a generic failure.
51
+ */
52
+ export declare function uploadOutcome(envelope: UploadEnvelope): UploadOutcome;
@@ -0,0 +1,48 @@
1
+ import { mediaToken } from '../media/reference.js';
2
+ // The server refuse reasons mapped to a card kind. `too-large` keeps its own card; an unsupported
3
+ // type reads as a decode failure to the author (the bytes the browser sent are a type the server
4
+ // will not store); `session-expired` is its own outcome. Every other reason (binding-missing,
5
+ // media-disabled, csrf, length-required, hash-collision) is an operational refusal with no
6
+ // author-actionable specifics, so it collapses to the generic card.
7
+ const REFUSE_TO_FAILURE = {
8
+ 'too-large': 'too-large',
9
+ 'unsupported-type': 'decode-unsupported',
10
+ 'session-expired': 'session-expired',
11
+ };
12
+ /**
13
+ * Map a parsed upload envelope to the single outcome the popover acts on. A success envelope yields
14
+ * an `inserted` outcome carrying the reference, the record, and the dedup flag. A failure envelope
15
+ * maps its refuse reason to a typed card, with `session-expired` lifted to its own outcome. An
16
+ * opaque or status-0 response (the guard's `redirect: 'manual'` 303) is a session-expired signal, as
17
+ * is any redirect-typed result. An error-typed result with a real status is a generic failure.
18
+ */
19
+ export function uploadOutcome(envelope) {
20
+ switch (envelope.type) {
21
+ case 'success':
22
+ return {
23
+ kind: 'inserted',
24
+ // Re-derive the reference from the validated record fields rather than trusting the loose
25
+ // server `reference` string: the token is inserted unescaped into the markdown URL slot, so
26
+ // the insert depends only on grammar-constrained fields (a 16-hex hash, a slugified slug)
27
+ // instead of an arbitrary server string. Defense in depth, in case a future server path
28
+ // returns a reference that does not match the record.
29
+ reference: mediaToken({ slug: envelope.data.record.slug, hash: envelope.data.record.hash }),
30
+ record: envelope.data.record,
31
+ reused: envelope.data.reused,
32
+ };
33
+ case 'failure': {
34
+ const reason = envelope.data?.error ?? '';
35
+ const mapped = REFUSE_TO_FAILURE[reason];
36
+ if (mapped === 'session-expired')
37
+ return { kind: 'session-expired' };
38
+ return { kind: 'failed', failure: mapped ?? 'generic' };
39
+ }
40
+ case 'redirect':
41
+ case 'opaqueredirect':
42
+ return { kind: 'session-expired' };
43
+ case 'error':
44
+ // A manual-redirect Response surfaces as type 'opaqueredirect' or status 0; a status-0 error
45
+ // is that same expired-session signal. A real error status is a genuine transport failure.
46
+ return (envelope.status ?? 0) === 0 ? { kind: 'session-expired' } : { kind: 'failed', failure: 'generic' };
47
+ }
48
+ }
@@ -1,4 +1,5 @@
1
1
  import { resolveConcepts } from './concepts.js';
2
+ import { normalizeAssets } from '../media/config.js';
2
3
  /**
3
4
  * Fold an adapter and any extensions into the composed runtime (seam 2). The per-concept URL policy
4
5
  * is derived from the site config, the same source the delivery path uses, so the runtime and
@@ -33,6 +34,8 @@ export function composeRuntime({ adapter, siteConfig, extensions = [] }) {
33
34
  navMenu: adapter.navMenu,
34
35
  preview: adapter.preview,
35
36
  assets: adapter.assets,
37
+ resolvedAssets: normalizeAssets(adapter.assets),
38
+ mediaManifestPath: adapter.mediaManifestPath ?? 'src/content/.cairn/media.json',
36
39
  adminPanels,
37
40
  fieldTypes,
38
41
  };
@@ -23,6 +23,23 @@ export function frontmatterFromForm(fields, form) {
23
23
  .filter(Boolean)),
24
24
  ];
25
25
  break;
26
+ case 'image': {
27
+ // The hero submits three sub-fields under one key. An empty src means no hero, so omit the
28
+ // whole key. Alt is stored verbatim (it is not markdown, so no escaping). A blank caption
29
+ // is dropped so committed frontmatter stays minimal.
30
+ const src = String(form.get(`${field.name}.src`) ?? '').trim();
31
+ if (src === '')
32
+ break;
33
+ const value = {
34
+ src,
35
+ alt: String(form.get(`${field.name}.alt`) ?? ''),
36
+ };
37
+ const caption = String(form.get(`${field.name}.caption`) ?? '').trim();
38
+ if (caption !== '')
39
+ value.caption = caption;
40
+ data[field.name] = value;
41
+ break;
42
+ }
26
43
  default:
27
44
  // FormData.get returns null for an absent field; normalize to an empty string so
28
45
  // a caller reading a text value never gets null.
@@ -10,6 +10,10 @@ export interface ManifestEntry {
10
10
  summary?: string;
11
11
  draft: boolean;
12
12
  links: CairnRef[];
13
+ /** The content hashes of the media this entry references (its hero plus its body images). The
14
+ * main side of the media where-used index. Additive and optional: an entry with no media omits
15
+ * the key, and a manifest committed before this field still parses (absent reads as no refs). */
16
+ mediaRefs?: string[];
13
17
  }
14
18
  /** The whole corpus as one committed file. `version` guards a future shape migration. */
15
19
  export interface Manifest {
@@ -7,12 +7,16 @@ import { parseMarkdown } from './frontmatter.js';
7
7
  import { deriveExcerpt } from './excerpt.js';
8
8
  import { entryIdentity, asString } from './identity.js';
9
9
  import { extractCairnLinks } from './links.js';
10
+ import { extractMediaRefs } from './media-refs.js';
10
11
  /** Build one manifest entry from a content file. Drafts are included and flagged. The id, date, and
11
12
  * permalink come from entryIdentity, the same source content-index uses, so a cairn: link resolves to
12
13
  * one URL whether the admin preview reads the manifest or the public build reads the content index. */
13
14
  export function manifestEntryFromFile(descriptor, file) {
14
15
  const { frontmatter, body } = parseMarkdown(file.raw);
15
16
  const { id, date, permalink } = entryIdentity(descriptor, file.path, frontmatter);
17
+ // Set mediaRefs only when non-empty, so an image-free entry's row stays byte-identical to before
18
+ // (matching the optional-spread for date and summary).
19
+ const mediaRefs = extractMediaRefs(frontmatter, body, descriptor.fields);
16
20
  return {
17
21
  id,
18
22
  concept: descriptor.id,
@@ -24,6 +28,7 @@ export function manifestEntryFromFile(descriptor, file) {
24
28
  summary: deriveExcerpt(body, { description: asString(frontmatter.description) }) || undefined,
25
29
  draft: frontmatter.draft === true,
26
30
  links: extractCairnLinks(body),
31
+ ...(mediaRefs.length ? { mediaRefs } : {}),
27
32
  };
28
33
  }
29
34
  /** An empty manifest, the starting point when no committed file exists yet. */
@@ -45,6 +50,7 @@ export function serializeManifest(manifest) {
45
50
  ...(e.summary ? { summary: e.summary } : {}),
46
51
  draft: e.draft,
47
52
  links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
53
+ ...(e.mediaRefs && e.mediaRefs.length ? { mediaRefs: [...e.mediaRefs].sort() } : {}),
48
54
  }));
49
55
  return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
50
56
  }
@@ -74,10 +80,21 @@ export function parseManifest(raw) {
74
80
  typeof e.draft === 'boolean' &&
75
81
  (e.date === undefined || typeof e.date === 'string') &&
76
82
  (e.summary === undefined || typeof e.summary === 'string') &&
83
+ (e.mediaRefs === undefined || Array.isArray(e.mediaRefs)) &&
77
84
  Array.isArray(e.links);
78
85
  if (!ok) {
79
86
  throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
80
87
  }
88
+ // mediaRefs is additive and optional: an entry without it parses (the field reads as absent),
89
+ // so a manifest committed before this field still builds. When present, validate each element
90
+ // is a string, mirroring the link-element validation, so a hand-edited file fails loudly.
91
+ if (e.mediaRefs !== undefined) {
92
+ for (const hash of e.mediaRefs) {
93
+ if (typeof hash !== 'string') {
94
+ throw new Error(`content manifest: malformed mediaRefs element ${JSON.stringify(hash)} in entry ${JSON.stringify(e)}`);
95
+ }
96
+ }
97
+ }
81
98
  // Validate each link element's shape, not just that links is an array. inboundLinks and the
82
99
  // delete guard read l.concept and l.id, so a string, null, or id-less element would read as
83
100
  // undefined and silently drop a real inbound linker. Reject it here instead.
@@ -132,11 +149,34 @@ export function verifyManifest(built, committedRaw) {
132
149
  const builtRaw = serializeManifest(built);
133
150
  if (committedRaw === builtRaw)
134
151
  return;
152
+ // mediaRefs is additive: a site whose committed manifest predates the field must still build,
153
+ // even when its content references media (open risk 3, the migration landmine). Before diffing,
154
+ // normalize the built manifest against the committed one: for any built entry whose committed
155
+ // counterpart carries no mediaRefs key, drop mediaRefs from the built entry. An un-regenerated
156
+ // site (committed omits mediaRefs) then matches; a regenerated site (committed carries mediaRefs)
157
+ // still detects real drift in that field. The normalization is per entry and per missing key, so
158
+ // it never masks drift in any other field or in an entry the committed manifest already tracks.
159
+ const committed = parseManifest(committedRaw);
160
+ const committedByKey = new Map(committed.entries.map((e) => [keyOf(e), e]));
161
+ const normalized = {
162
+ version: 1,
163
+ entries: built.entries.map((b) => {
164
+ const c = committedByKey.get(keyOf(b));
165
+ if (b.mediaRefs && c && c.mediaRefs === undefined) {
166
+ const { mediaRefs: _dropped, ...rest } = b;
167
+ return rest;
168
+ }
169
+ return b;
170
+ }),
171
+ };
172
+ const normalizedRaw = serializeManifest(normalized);
173
+ if (committedRaw === normalizedRaw)
174
+ return;
135
175
  // Diff the canonical built form, not the raw one. serializeManifest sorts each entry's links, so a
136
176
  // build whose links are in extraction order would otherwise report a false (links) drift for an
137
177
  // entry whose link set is identical and only the order differs. Reuse the serialized form so both
138
178
  // sides are canonical.
139
- const diff = diffManifests(parseManifest(builtRaw), parseManifest(committedRaw));
179
+ const diff = diffManifests(parseManifest(normalizedRaw), committed);
140
180
  throw new Error('content manifest is stale: the committed file does not match the corpus.\n' +
141
181
  formatDiff(diff) +
142
182
  '\nRegenerate it (npm run cairn:manifest) and commit the result.');
@@ -0,0 +1,7 @@
1
+ import type { FrontmatterField } from './types.js';
2
+ /** The content hashes one entry references, in first-occurrence order, deduped by hash. Reads the
3
+ * frontmatter hero `image.src` for each `image`-typed field plus every body image node. A
4
+ * non-media or malformed token is skipped, never thrown, so a stray `![](/x.png)` does not break
5
+ * the manifest build. The body is parsed as mdast, so a `media:` token inside a code span or fence
6
+ * is never matched. */
7
+ export declare function extractMediaRefs(frontmatter: Record<string, unknown>, body: string, fields: FrontmatterField[]): string[];
@@ -0,0 +1,52 @@
1
+ // cairn-cms: the media-reference extractor. Given one entry's parsed frontmatter and body, it
2
+ // returns the deduped content hashes the entry references. This is the main side of the media
3
+ // where-used index: manifestEntryFromFile records the result per entry, and the usage-index
4
+ // builder runs it directly over each open branch's edited markdown. It mirrors extractCairnLinks
5
+ // (the same remark pipeline, the same first-occurrence dedup) but visits image nodes and the
6
+ // frontmatter hero rather than link nodes.
7
+ //
8
+ // A media reference lives in two places, and both are load-bearing. Body image nodes carry the
9
+ // inline `![](media:...)` placements (a 3a :::figure also lands here, since the figure directive
10
+ // wraps a real image node). The frontmatter hero is the other site: a hero is `image: { src }` in
11
+ // frontmatter, outside the markdown body, so an extractor that visited only body nodes would read
12
+ // every in-use hero as orphaned and let safe-delete remove an in-use image.
13
+ //
14
+ // Every match is keyed by the parsed hash, the immutable truth, never the cosmetic slug, so a bare
15
+ // `media:<hash>` and a `media:<slug>.<hash>` for the same bytes collapse to one.
16
+ import { unified } from 'unified';
17
+ import remarkParse from 'remark-parse';
18
+ import remarkGfm from 'remark-gfm';
19
+ import { visit } from 'unist-util-visit';
20
+ import { parseMediaToken } from '../media/reference.js';
21
+ /** The content hashes one entry references, in first-occurrence order, deduped by hash. Reads the
22
+ * frontmatter hero `image.src` for each `image`-typed field plus every body image node. A
23
+ * non-media or malformed token is skipped, never thrown, so a stray `![](/x.png)` does not break
24
+ * the manifest build. The body is parsed as mdast, so a `media:` token inside a code span or fence
25
+ * is never matched. */
26
+ export function extractMediaRefs(frontmatter, body, fields) {
27
+ const seen = new Set();
28
+ const hashes = [];
29
+ const add = (href) => {
30
+ const ref = parseMediaToken(href);
31
+ if (!ref || seen.has(ref.hash))
32
+ return;
33
+ seen.add(ref.hash);
34
+ hashes.push(ref.hash);
35
+ };
36
+ // The frontmatter hero arm: each `image`-typed field stores an ImageValue, so read its `.src`.
37
+ for (const field of fields) {
38
+ if (field.type !== 'image')
39
+ continue;
40
+ const value = frontmatter[field.name];
41
+ if (value && typeof value === 'object' && typeof value.src === 'string') {
42
+ add(value.src);
43
+ }
44
+ }
45
+ // The body arm: every image node's url. A 3a figure's inner image is a real image node.
46
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
47
+ visit(tree, 'image', (node) => {
48
+ if (node.url)
49
+ add(node.url);
50
+ });
51
+ return hashes;
52
+ }
@@ -1,4 +1,4 @@
1
- import type { FrontmatterField, ValidationResult } from './types.js';
1
+ import type { FrontmatterField, ImageValue, ValidationResult } from './types.js';
2
2
  /** The validate input the cairn adapter takes: the raw frontmatter and the body. */
3
3
  export interface StandardInput {
4
4
  frontmatter: Record<string, unknown>;
@@ -27,10 +27,13 @@ type StandardResult<Output> = {
27
27
  }>;
28
28
  };
29
29
  /** Map one field descriptor to the TS type of its normalized value. text, textarea, and date
30
- * normalize to a string; a closed-vocabulary `tags` field to the option-union array. */
30
+ * normalize to a string; a closed-vocabulary `tags` field to the option-union array; an `image`
31
+ * field to its nested object. */
31
32
  type FieldValue<K extends FrontmatterField> = K extends {
32
33
  type: 'boolean';
33
34
  } ? boolean : K extends {
35
+ type: 'image';
36
+ } ? ImageValue : K extends {
34
37
  type: 'tags';
35
38
  options: readonly (infer O extends string)[];
36
39
  } ? O[] : K extends {
@@ -41,10 +41,27 @@ function compilePatterns(fields) {
41
41
  }
42
42
  return compiled;
43
43
  }
44
+ // True when an image field feeds the social card: an explicit `seo: true`, or the back-compat
45
+ // default that the field named `image` is the SEO image. The SEO unify (Task 4) reads this flag.
46
+ function isSeoImage(field) {
47
+ return field.type === 'image' && (field.seo === true || (field.seo === undefined && field.name === 'image'));
48
+ }
49
+ // A concept declares at most one SEO image field, so the social card is unambiguous. More than one
50
+ // is a site config error: a hero named `cover` plus an explicit `seo` on another, or two explicit
51
+ // `seo` fields. Fail loudly at declaration rather than emit a silent or wrong og:image.
52
+ function checkSeoImageFields(fields) {
53
+ const seo = fields.filter(isSeoImage);
54
+ if (seo.length > 1) {
55
+ const names = seo.map((field) => `"${field.name}"`).join(', ');
56
+ throw new Error(`cairn: a concept declares at most one SEO image field, but found ${seo.length} (${names}). ` +
57
+ 'Set seo: false on all but one, or rename the extra image fields so only one feeds the social card.');
58
+ }
59
+ }
44
60
  /** Declare a concept's fields once. Returns the schema's faces derived from that one declaration. */
45
61
  export function defineFields(fields, options = {}) {
46
62
  const list = [...fields];
47
63
  const patterns = compilePatterns(list);
64
+ checkSeoImageFields(list);
48
65
  const validate = (frontmatter, body) => {
49
66
  const base = validateFields(list, frontmatter);
50
67
  if (!base.ok)