@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
@@ -25,6 +25,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
25
25
  import LinkIcon from '@lucide/svelte/icons/link';
26
26
  import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
27
27
  import PanelRightIcon from '@lucide/svelte/icons/panel-right';
28
+ import ImageIcon from '@lucide/svelte/icons/image';
28
29
  import { useTopbar } from './topbar-context.js';
29
30
  import CsrfField from './CsrfField.svelte';
30
31
  import MarkdownEditor from './MarkdownEditor.svelte';
@@ -32,21 +33,37 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
32
33
  import ComponentInsertDialog, { insertableDefs, hasSchema } from './ComponentInsertDialog.svelte';
33
34
  import LinkPicker from './LinkPicker.svelte';
34
35
  import WebLinkDialog from './WebLinkDialog.svelte';
36
+ import MediaInsertPopover from './MediaInsertPopover.svelte';
37
+ import MediaHeroField from './MediaHeroField.svelte';
38
+ import MediaFigureControl from './MediaFigureControl.svelte';
35
39
  import DeleteDialog from './DeleteDialog.svelte';
36
40
  import RenameDialog from './RenameDialog.svelte';
37
41
  import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
38
42
  import ShortcutsDialog from './ShortcutsDialog.svelte';
39
43
  import { cairnLinkCompletionSource } from './link-completion.js';
40
- import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
44
+ import {
45
+ findMediaImagesNeedingAlt,
46
+ unwrapCairnLink,
47
+ unwrapFigure,
48
+ updateFigure,
49
+ wrapImageInFigure,
50
+ type FigureAtImage,
51
+ type FigureRole,
52
+ type FormatKind,
53
+ } from './markdown-format.js';
41
54
  import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
42
55
  import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
43
56
  import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
44
57
  import { parseComponent, componentRoundTripSafety } from '../render/component-grammar.js';
45
58
  import type { IconSet } from '../render/glyph.js';
46
59
  import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
47
- import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
60
+ import type { TextareaField, TagsField, FreeTagsField, ImageValue } from '../content/types.js';
48
61
  import type { LinkResolve } from '../content/links.js';
49
62
  import { manifestLinkResolver } from '../content/manifest.js';
63
+ import type { MediaResolve } from '../render/resolve-media.js';
64
+ import { manifestMediaResolver } from '../render/resolve-media.js';
65
+ import type { MediaEntry } from '../media/manifest.js';
66
+ import { mediaLibraryEntry } from '../media/library-entry.js';
50
67
 
51
68
  interface Props {
52
69
  /** The edit load's data, plus the site name for the heading. */
@@ -54,7 +71,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
54
71
  /** The site's component registry, for the insert palette. */
55
72
  registry?: ComponentRegistry;
56
73
  /** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
57
- render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
74
+ render?: (
75
+ md: string,
76
+ opts?: { stagger?: boolean; resolve?: LinkResolve; resolveMedia?: MediaResolve },
77
+ ) => string | Promise<string>;
58
78
  /** The site's icon set, for the guided form's icon fields. */
59
79
  icons?: IconSet;
60
80
  /** The last content action's failure: the save guard's broken links, the delete guard's
@@ -126,6 +146,12 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
126
146
  if (target?.closest('dialog, #cairn-pane-write')) return;
127
147
  fieldsDirty = true;
128
148
  }
149
+ // Mark the details fields dirty without a form input event. The hero field writes its value to
150
+ // hidden inputs, whose programmatic value changes do not fire the form's oninput, so it signals
151
+ // dirty through this helper instead.
152
+ function markFieldsDirty() {
153
+ fieldsDirty = true;
154
+ }
129
155
 
130
156
  // The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
131
157
  let editForm = $state<HTMLFormElement | null>(null);
@@ -362,12 +388,62 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
362
388
  // The editor's range-replace seam, registered by MarkdownEditor on mount; the dialog's Update
363
389
  // routes through it to overwrite an edited block's source span. A no-op until then.
364
390
  let replaceRange = $state.raw<(from: number, to: number, text: string) => void>(() => {});
391
+ // The editor's select-range seam, registered by MarkdownEditor on mount; the needs-alt notice's
392
+ // jump control routes through it to land the author on an image that lacks alt. A no-op until then.
393
+ let selectRange = $state.raw<(from: number, to: number) => void>(() => {});
365
394
  let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
366
395
  // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
367
396
  // reads it for the Text field's default.
368
397
  let getSelection = $state.raw<() => string>(() => '');
369
398
  // The editor's selection transform, registered by MarkdownEditor on mount; a no-op until then.
370
399
  let format = $state.raw<(kind: FormatKind) => void>(() => {});
400
+
401
+ // The media insert seams, registered by MarkdownEditor on mount, mirroring the range holders
402
+ // above. The popover drives the optimistic upload loop through them: the caret anchor, the focus
403
+ // restore, the placeholder api, and the direct-insert path for a picked image. The placeholder
404
+ // api type is referenced inline (import('...').Type), never a static `import type ... from`, so
405
+ // no static edge to the dynamically-imported editor-placeholder module sits in this component
406
+ // (the editor-boundary test bars that edge by a textual `from` scan).
407
+ let caretCoords = $state.raw<() => { left: number; right: number; top: number; bottom: number } | null>(
408
+ () => null,
409
+ );
410
+ let focusEditor = $state.raw<() => void>(() => {});
411
+ let placeholders = $state.raw<import('./editor-placeholder.js').ImagePlaceholderApi | null>(null);
412
+ let insertImageFn = $state.raw<(alt: string, ref: string) => void>(() => {});
413
+
414
+ // A no-op placeholder api so the editor object handed to the popover is never null before the
415
+ // editor registers its real one on mount.
416
+ const noopPlaceholders: import('./editor-placeholder.js').ImagePlaceholderApi = {
417
+ begin: () => 0,
418
+ progress: () => {},
419
+ resolveTo: () => {},
420
+ cancel: () => {},
421
+ };
422
+
423
+ // The editor object the popover drives, delegating through the holders so the latest registered
424
+ // function is always used (the holders start as no-ops and are replaced on mount).
425
+ const editorApi = $derived({
426
+ caretCoords: () => caretCoords(),
427
+ focusEditor: () => focusEditor(),
428
+ placeholders: placeholders ?? noopPlaceholders,
429
+ insertImage: (alt: string, ref: string) => insertImageFn(alt, ref),
430
+ });
431
+
432
+ // The headless media insert popover, opened from the toolbar control, paste, or drop.
433
+ let mediaPopover = $state<MediaInsertPopover | null>(null);
434
+
435
+ // The rendered hero fields' refs (for the needs-alt notice's "Add alt text" action, which focuses
436
+ // the field's own alt input) and their reported needs-alt signals, keyed by field name. A hero is
437
+ // a frontmatter value with no body offset, so its needs-alt signal comes from the field, not the
438
+ // body scanner (findMediaImagesNeedingAlt), and its remediation focuses the alt input, never a
439
+ // source range (selectRange). The records are keyed by field name; `data.fields` is static for the
440
+ // page's lifetime, so a key never goes stale (no per-key cleanup on unmount is needed).
441
+ let heroFieldRefs = $state<Record<string, MediaHeroField>>({});
442
+ let heroNeedsAlt = $state<Record<string, boolean>>({});
443
+
444
+ // The server-owned records from each successful upload this session. They ride the save form as
445
+ // the hidden `media` field, so the save action merges them into media.json.
446
+ let uploadedRecords = $state<MediaEntry[]>([]);
371
447
  // A headless dialog instance, typed structurally over its exported open() (the linkPicker idiom).
372
448
  type DialogHandle = { open: () => void };
373
449
  // The toolbar's insert dialogs. Each holds its own <form>, so they mount outside the edit form
@@ -397,6 +473,35 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
397
473
  // matching interface is not exported.
398
474
  type CaretComponent = { name: string | null; markdown: string; from: number; to: number };
399
475
  let caretComponent = $state<CaretComponent | null>(null);
476
+
477
+ // The media image at the editor caret, reported by MarkdownEditor whenever it changes (null off any
478
+ // media image). The Figure control reads it to wrap a bare image or edit an existing figure; it
479
+ // writes source through the replaceRange seam. The figure dialog is mounted headless below.
480
+ let mediaAtCaret = $state<FigureAtImage | null>(null);
481
+ // The figure control's host <dialog>, opened by the toolbar control. Mounted outside the edit form
482
+ // (a form nested in a form is invalid HTML), the Edit-block dialog pattern.
483
+ let figureDialog = $state<HTMLDialogElement | null>(null);
484
+ // Whether the Figure control is available: a media image sits at the caret and Preview is not
485
+ // showing (the insert controls disable together with the Write surface). The control is always
486
+ // rendered (it never mounts on caret move); only its enabled state changes.
487
+ const figureAvailable = $derived(mediaAtCaret != null && !insertDisabled);
488
+ const figureLabel = $derived(
489
+ figureAvailable
490
+ ? mediaAtCaret?.figure
491
+ ? 'Edit the figure at the cursor'
492
+ : 'Wrap the image at the cursor in a figure'
493
+ : 'Place the cursor on an image to add a figure',
494
+ );
495
+ // Whether the image at the caret is decorative (empty or whitespace-only alt). The token came from
496
+ // a parsed image node, so the alt is the source between `![` and the closing `]` before `](`. An
497
+ // empty alt is the needs-alt signal; the figure control surfaces it and the decorative-plus-caption
498
+ // warning. Derived from the reported token so it tracks the caret.
499
+ const figureDecorative = $derived.by(() => {
500
+ if (!mediaAtCaret) return false;
501
+ const token = body.slice(mediaAtCaret.imageFrom, mediaAtCaret.imageTo);
502
+ const match = /^!\[([\s\S]*?)\]\(/.exec(token);
503
+ return (match?.[1] ?? '').trim() === '';
504
+ });
400
505
  // A def is actionable for guided edit when it has a schema (the same notion the insert catalog
401
506
  // lists by): a template-only def has no form to re-open into. Reuses the dialog's exported
402
507
  // hasSchema so the two surfaces can never drift on what counts as editable.
@@ -476,6 +581,68 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
476
581
  insertDialog?.editComponent(editable.def, values, editable.range);
477
582
  }
478
583
 
584
+ // The figure dialog's pre-fill, snapshotted when the control opens so the form never mixes a newer
585
+ // caret with the values it opened on. Captured from mediaAtCaret at open time: edit mode with the
586
+ // figure's caption/role when a figure wraps the image, else wrap mode with empty caption and the
587
+ // measure default. decorative rides the snapshot too. Null while the dialog is closed.
588
+ let figurePrefill = $state<{
589
+ mode: 'wrap' | 'edit';
590
+ caption: string;
591
+ role: FigureRole | null;
592
+ decorative: boolean;
593
+ image: { from: number; to: number };
594
+ figureRange: { from: number; to: number } | null;
595
+ } | null>(null);
596
+
597
+ // Open the figure control over the media image at the caret. Inert unless a media image sits there
598
+ // and the Write surface is up, the same gate the toolbar control shows. The snapshot is the source
599
+ // of truth for the apply handlers, so a caret move while the dialog is open never re-targets it.
600
+ function openFigure() {
601
+ if (!figureAvailable || !mediaAtCaret) return;
602
+ const at = mediaAtCaret;
603
+ figurePrefill = {
604
+ mode: at.figure ? 'edit' : 'wrap',
605
+ caption: at.figure?.caption ?? '',
606
+ role: at.figure?.role ?? null,
607
+ decorative: figureDecorative,
608
+ image: { from: at.imageFrom, to: at.imageTo },
609
+ figureRange: at.figure ? { from: at.figure.from, to: at.figure.to } : null,
610
+ };
611
+ figureDialog?.showModal();
612
+ }
613
+
614
+ // Apply the control's choice through the replaceRange seam, then close. Wrap a bare image or update
615
+ // an existing figure, off the snapshot the dialog opened on. The pure transform owns the source
616
+ // shape and keeps the media token byte-intact; the preview stays read-only.
617
+ function applyFigure(choice: { caption: string; role: FigureRole | null }) {
618
+ const pre = figurePrefill;
619
+ if (!pre) return;
620
+ const result =
621
+ pre.mode === 'edit' && pre.figureRange
622
+ ? updateFigure(body, pre.figureRange, choice.caption, choice.role)
623
+ : wrapImageInFigure(body, pre.image.from, pre.image.to, choice.caption, choice.role);
624
+ writeFigureResult(result);
625
+ }
626
+
627
+ // Unwrap the figure back to its bare image, then close. Edit mode only (the snapshot carries the
628
+ // figure range). The bare image token is restored verbatim by the pure transform.
629
+ function unwrapFigureAction() {
630
+ const pre = figurePrefill;
631
+ if (!pre || !pre.figureRange) return;
632
+ writeFigureResult(unwrapFigure(body, pre.figureRange));
633
+ }
634
+
635
+ // Write a figure transform's result back to the editor: overwrite the whole doc through the
636
+ // replaceRange seam, then place the selection the transform chose (the seam alone drops the caret
637
+ // at the end). replaceRange dispatches the doc change and focuses the surface; selectRange then
638
+ // dispatches a selection-only transaction, which CodeMirror's history does not record as its own
639
+ // undoable event, so one undo reverts the whole figure write. Close the dialog last.
640
+ function writeFigureResult(result: { doc: string; from: number; to: number }) {
641
+ replaceRange(0, body.length, result.doc);
642
+ selectRange(result.from, result.to);
643
+ figureDialog?.close();
644
+ }
645
+
479
646
  // The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
480
647
  // when it has never been published); otherwise the live site matches and it reads Published.
481
648
  const status = $derived.by(() => {
@@ -541,6 +708,21 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
541
708
  }
542
709
  }
543
710
 
711
+ // The media images in the live body that carry no alt text, recomputed as the author types. Alt is
712
+ // accessibility debt, never a render or publish failure, so this drives a non-blocking warning the
713
+ // author can act on or leave; the count drops and the notice clears as each alt is filled.
714
+ const needsAlt = $derived(findMediaImagesNeedingAlt(body));
715
+
716
+ // The declared image (hero) fields, for labelling the needs-alt notice's frontmatter rows.
717
+ const imageFields = $derived(
718
+ data.fields.filter((f) => f.type === 'image').map((f) => ({ name: f.name, label: f.label })),
719
+ );
720
+ // The frontmatter-hero needs-alt rows: each image field whose hero reports a needs-alt signal. The
721
+ // row's action focuses the field's alt input (the body scanner and its source-range jump cannot
722
+ // reach a frontmatter value). The headline count sums these with the body scanner's hits.
723
+ const heroRows = $derived(imageFields.filter((f) => heroNeedsAlt[f.name]));
724
+ const needsAltCount = $derived(needsAlt.length + heroRows.length);
725
+
544
726
  // The delete guard's inbound linkers, from a refused delete (fail 409). Empty when the delete was
545
727
  // not refused. When set, a delete was blocked by a link that appeared since the page loaded.
546
728
  const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
@@ -653,6 +835,28 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
653
835
  // returns undefined for a missing target so the render step marks it cairn-broken-link.
654
836
  const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
655
837
 
838
+ // The media analog: it turns a media: reference into its /media delivery path in the preview, and
839
+ // returns undefined for a missing target so the render step marks it cairn-broken-media. The
840
+ // committed mediaTargets projection is merged with this session's uploaded records (the same
841
+ // override the picker's library does), so a just-uploaded image renders its thumbnail in the live
842
+ // preview before the next save commits it, rather than reading as a broken reference.
843
+ const resolveMediaTargets = $derived({
844
+ ...data.mediaTargets,
845
+ ...Object.fromEntries(
846
+ uploadedRecords.map((r) => [r.hash, { slug: r.slug, ext: r.ext, contentType: r.contentType }]),
847
+ ),
848
+ });
849
+ const resolveMedia = $derived(manifestMediaResolver(resolveMediaTargets));
850
+
851
+ // The picker's library, the committed projection merged with this session's uploaded records,
852
+ // keyed by content hash. An uploaded record overrides a committed entry on a hash match (the same
853
+ // hash is the same bytes, so the override is harmless). This is what the editor decorates with, so
854
+ // a just-uploaded image carries its source chip before the next save commits it.
855
+ const mediaLibrary = $derived({
856
+ ...data.mediaLibrary,
857
+ ...Object.fromEntries(uploadedRecords.map((r) => [r.hash, mediaLibraryEntry(r)])),
858
+ });
859
+
656
860
  // The [[ autocomplete source over the same link targets, handed to the editor's generic seam.
657
861
  const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
658
862
 
@@ -719,10 +923,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
719
923
  if (mode !== 'preview' || !render) return;
720
924
  const md = body;
721
925
  const resolve = resolveLink; // tracked read in the effect body
926
+ const resolveMediaRef = resolveMedia; // tracked read in the effect body
722
927
  const run = ++previewRun;
723
928
  const handle = setTimeout(async () => {
724
929
  try {
725
- const html = await render(md, { resolve });
930
+ const html = await render(md, { resolve, resolveMedia: resolveMediaRef });
726
931
  if (run === previewRun) {
727
932
  previewHtml = html;
728
933
  previewFailed = false;
@@ -921,6 +1126,45 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
921
1126
  </ul>
922
1127
  </div>
923
1128
  {/if}
1129
+ <!-- The publish-time needs-alt notice: a non-blocking warning, never a block. Alt text is
1130
+ accessibility debt, so the author can add it now or save without it; the count drops live as
1131
+ each alt is filled and the notice clears at zero. The leading glyph carries the state alongside
1132
+ the count, so the caution reads without relying on hue. Each row's jump control selects the
1133
+ image in the source through the editor's select-range seam, landing the author on it to type
1134
+ the alt. The role="status" live region renders unconditionally (present and empty at load), so
1135
+ when the first debt appears its count change announces; a region conditionally mounted with its
1136
+ first content may not be observed by assistive tech (WCAG 4.1.3). The visible alert chrome and
1137
+ content gate on the count, so an empty region shows nothing. A plain wrapper (not display:contents)
1138
+ carries the role, since some assistive tech drops a role off a display:contents box. -->
1139
+ <div role="status">
1140
+ {#if needsAltCount}
1141
+ <div class="alert alert-warning mb-4 flex-col items-start text-sm">
1142
+ <p class="flex items-center gap-2 font-medium">
1143
+ <svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
1144
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
1145
+ </svg>
1146
+ <span>{needsAltCount} {needsAltCount === 1 ? 'image needs' : 'images need'} alt text</span>
1147
+ </p>
1148
+ <p>Alt text describes an image for readers who cannot see it. Add it now, or save and come back to it.</p>
1149
+ <ul class="mt-1 w-full">
1150
+ {#each needsAlt as item (item.from)}
1151
+ <li class="flex items-center justify-between gap-2">
1152
+ <code class="text-xs">{item.ref}</code>
1153
+ <button type="button" class="btn btn-xs" onclick={() => selectRange(item.from, item.to)}>Add alt text</button>
1154
+ </li>
1155
+ {/each}
1156
+ <!-- The frontmatter-hero rows: a hero has no body offset, so its action focuses the field's
1157
+ own alt input rather than a source range. -->
1158
+ {#each heroRows as hero (hero.name)}
1159
+ <li class="flex items-center justify-between gap-2">
1160
+ <span class="text-xs font-medium">{hero.label}</span>
1161
+ <button type="button" class="btn btn-xs" onclick={() => heroFieldRefs[hero.name]?.focusAlt()}>Add alt text</button>
1162
+ </li>
1163
+ {/each}
1164
+ </ul>
1165
+ </div>
1166
+ {/if}
1167
+ </div>
924
1168
  {#if draftWarning}
925
1169
  <div class="alert alert-warning mb-4 text-sm">
926
1170
  Saved. Note: this page links to unpublished {draftWarning.includes(',') ? 'pages' : 'a page'} ({draftWarning}), which will 404 until published.
@@ -1040,9 +1284,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1040
1284
  <button
1041
1285
  type="button"
1042
1286
  class="btn btn-ghost btn-sm btn-square"
1043
- disabled
1044
- aria-label="Image (coming soon)"
1045
- title="Image (coming soon)"
1287
+ disabled={insertDisabled}
1288
+ aria-label="Insert image"
1289
+ title="Insert image"
1290
+ onclick={() => mediaPopover?.open('chooser')}
1046
1291
  >
1047
1292
  <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
1048
1293
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
@@ -1050,6 +1295,26 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1050
1295
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
1051
1296
  </svg>
1052
1297
  </button>
1298
+ <!-- The Figure control: always rendered, enabled only when the caret sits on a media image
1299
+ (and the Write surface is up). It never mounts or unmounts on caret movement; only its
1300
+ enabled state changes (the Edit-block pattern). The unavailable state uses aria-disabled,
1301
+ not the native disabled attribute, so the control stays focusable and its reason reaches
1302
+ assistive technology; openFigure() early-returns so the dead click is inert. The dimming
1303
+ uses opacity and cursor utilities, never .btn-disabled, because that sets
1304
+ pointer-events: none and would suppress the title tooltip a mouse user reads for the why. -->
1305
+ <button
1306
+ type="button"
1307
+ class="btn btn-sm btn-ghost btn-square"
1308
+ class:opacity-50={!figureAvailable}
1309
+ class:cursor-not-allowed={!figureAvailable}
1310
+ aria-haspopup="dialog"
1311
+ aria-label={figureLabel}
1312
+ title={figureLabel}
1313
+ aria-disabled={!figureAvailable}
1314
+ onclick={openFigure}
1315
+ >
1316
+ <ImageIcon class="h-4 w-4" aria-hidden="true" />
1317
+ </button>
1053
1318
  {/snippet}
1054
1319
  </EditorToolbar>
1055
1320
  {/if}
@@ -1062,14 +1327,25 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1062
1327
  {surface}
1063
1328
  registerInsert={(fn) => (insert = fn)}
1064
1329
  onComponentAtCaret={(info) => (caretComponent = info)}
1330
+ onMediaImageAtCaret={(info) => (mediaAtCaret = info)}
1065
1331
  registerReplaceRange={(fn) => (replaceRange = fn)}
1332
+ registerSelectRange={(fn) => (selectRange = fn)}
1066
1333
  registerInsertLink={(fn) => (insertLink = fn)}
1067
1334
  registerGetSelection={(fn) => (getSelection = fn)}
1068
1335
  registerFormat={(fn) => (format = fn)}
1336
+ registerCaretCoords={(fn) => (caretCoords = fn)}
1337
+ registerFocusEditor={(fn) => (focusEditor = fn)}
1338
+ registerImagePlaceholders={(api) => (placeholders = api)}
1339
+ registerInsertImage={(fn) => (insertImageFn = fn)}
1340
+ onImageIngest={(file) => mediaPopover?.open('capture', file)}
1069
1341
  {completionSources}
1342
+ {mediaLibrary}
1070
1343
  {focusMode}
1071
1344
  {typewriter}
1072
1345
  />
1346
+ <!-- The accumulated uploaded records ride the save form alongside the body. The save action
1347
+ reads `media` and merges these records into media.json (publish submits the same form). -->
1348
+ <input type="hidden" name="media" value={JSON.stringify(uploadedRecords)} />
1073
1349
  </div>
1074
1350
  {#if mode === 'preview'}
1075
1351
  <!-- The preview ground: recessed under the floating frame card so the page reads as a
@@ -1290,6 +1566,19 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1290
1566
  value={tagValue}
1291
1567
  />
1292
1568
  </label>
1569
+ {:else if field.type === 'image'}
1570
+ {@const heroValue = data.frontmatter[field.name] as ImageValue | undefined}
1571
+ <MediaHeroField
1572
+ bind:this={heroFieldRefs[field.name]}
1573
+ field={{ name: field.name, label: field.label }}
1574
+ value={heroValue}
1575
+ mediaLibrary={mediaLibrary}
1576
+ conceptId={data.conceptId}
1577
+ id={data.id}
1578
+ onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
1579
+ ondirty={markFieldsDirty}
1580
+ onneedsaltchange={(n) => (heroNeedsAlt = { ...heroNeedsAlt, [field.name]: n })}
1581
+ />
1293
1582
  {:else}
1294
1583
  <label class="flex flex-col gap-1">
1295
1584
  <span class="text-sm font-medium">{field.label}</span>
@@ -1365,6 +1654,57 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1365
1654
  <WebLinkDialog bind:this={webLinkDialog} trigger={false} insert={insertLink} selection={getSelection} />
1366
1655
  <LinkPicker bind:this={linkPicker} trigger={false} linkTargets={data.linkTargets} insert={insertLink} />
1367
1656
 
1657
+ <!-- The media insert popover, mounted headless: the toolbar control, a paste, or a drop drives it
1658
+ through its exported open(). On a successful upload it hands the server-owned record up; the
1659
+ record joins uploadedRecords (the hidden save field) and the merged library (the source chip). -->
1660
+ <MediaInsertPopover
1661
+ bind:this={mediaPopover}
1662
+ trigger={false}
1663
+ conceptId={data.conceptId}
1664
+ id={data.id}
1665
+ library={mediaLibrary}
1666
+ editor={editorApi}
1667
+ onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
1668
+ />
1669
+
1670
+ <!-- The figure control's host dialog, mounted headless outside the edit form (the control holds its
1671
+ own <form>). The toolbar Figure control opens it through openFigure(), pre-filled from the caret
1672
+ snapshot. The control is keyed on figurePrefill so it remounts fresh per open, seeding its fields
1673
+ from the new caption/role. The native <dialog> gives the focus trap and Escape for free, and the
1674
+ close event (the X, the backdrop, Escape, and the apply path all fire it) clears the snapshot so
1675
+ the host state matches the closed dialog. -->
1676
+ <dialog
1677
+ class="modal"
1678
+ aria-labelledby="cairn-figure-dialog-title"
1679
+ bind:this={figureDialog}
1680
+ onclose={() => (figurePrefill = null)}
1681
+ >
1682
+ <div class="modal-box max-w-sm">
1683
+ <div class="mb-3 flex items-center justify-between">
1684
+ <h2 id="cairn-figure-dialog-title" class="flex items-center gap-2 text-base font-semibold">
1685
+ <ImageIcon class="h-4 w-4 text-[var(--color-accent)]" aria-hidden="true" />
1686
+ {figurePrefill?.mode === 'edit' ? 'Edit figure' : 'Wrap in a figure'}
1687
+ </h2>
1688
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => figureDialog?.close()}>✕</button>
1689
+ </div>
1690
+ {#if figurePrefill}
1691
+ {#key figurePrefill}
1692
+ <MediaFigureControl
1693
+ caption={figurePrefill.caption}
1694
+ role={figurePrefill.role}
1695
+ mode={figurePrefill.mode}
1696
+ decorative={figurePrefill.decorative}
1697
+ onapply={applyFigure}
1698
+ onunwrap={unwrapFigureAction}
1699
+ />
1700
+ {/key}
1701
+ {/if}
1702
+ </div>
1703
+ <form method="dialog" class="modal-backdrop">
1704
+ <button tabindex="-1" aria-label="Close">close</button>
1705
+ </form>
1706
+ </dialog>
1707
+
1368
1708
  <!-- The lifecycle dialogs, mounted headless: the header's overflow menu drives them through their
1369
1709
  exported open(). Their POST forms flip the leaving flag so the leave guard stands down. -->
1370
1710
  <RenameDialog