@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,213 @@
1
+ // The optimistic image placeholder. While an upload runs, the editor shows the image the author is
2
+ // placing right where the caret sits, with a determinate progress bar, so there is no dead wait. The
3
+ // placeholder is a WIDGET decoration at a position, NEVER doc text: the source markdown is untouched
4
+ // until the upload resolves, so a failed or session-expired upload leaves the source exactly as it
5
+ // was (the 2b open-risk-2 acceptance bar). On resolve the placeholder is removed and the real
6
+ // `![alt](media:slug.hash)` text is inserted in ONE transaction, so the surface never shows a frame
7
+ // with neither the placeholder nor the committed text.
8
+ //
9
+ // Client-only like editor-highlight, editor-modes, editor-folding, and editor-media: MarkdownEditor
10
+ // reaches this module through a dynamic import, so the static @codemirror imports here never enter a
11
+ // server bundle (guarded by the editor-boundary test, whose DYNAMIC_ONLY list names this file).
12
+ //
13
+ // The architecture mirrors editor-folding: a StateField<DecorationSet> of active placeholders driven
14
+ // by StateEffects (add, set-progress, remove). The field maps positions across doc changes
15
+ // (deco.map(tr.changes)) and tracks each placeholder's mapped position, so concurrent typing never
16
+ // strands a placeholder. The seam ops (begin, progress, resolveTo, cancel) dispatch the effects; the
17
+ // resolve op dispatches the remove effect AND the real text insert in one transaction.
18
+ import {
19
+ Decoration,
20
+ EditorView,
21
+ WidgetType,
22
+ type DecorationSet,
23
+ } from '@codemirror/view';
24
+ import { StateEffect, StateField, type Extension } from '@codemirror/state';
25
+ import { insertImage as insertImageFormat } from './markdown-format.js';
26
+
27
+ /** One active placeholder's data: its stable id, the object URL for the thumbnail, and the upload
28
+ * progress as a 0..1 fraction. The widget reads this to render the thumbnail and the bar. */
29
+ interface PlaceholderData {
30
+ id: number;
31
+ url: string;
32
+ fraction: number;
33
+ }
34
+
35
+ // The effects that drive the placeholder field. add lands a placeholder at a position; set-progress
36
+ // updates its bar; remove takes it out. resolveTo and cancel both end in a remove (resolveTo pairs
37
+ // the remove with the text insert in its dispatch; cancel dispatches only the remove).
38
+ const addPlaceholder = StateEffect.define<{ id: number; pos: number; url: string }>();
39
+ const setProgress = StateEffect.define<{ id: number; fraction: number }>();
40
+ const removePlaceholder = StateEffect.define<{ id: number }>();
41
+
42
+ // The widget: a small thumbnail from the local object URL plus a determinate progress bar, in the
43
+ // editor's accent visual language (the same accent tint the media chip uses). The wrapper carries no
44
+ // live region: the widget is recreated on every progress tick (CodeMirror replaces the decoration),
45
+ // so a live region on it could not reliably announce a change. The bar is a real <progress> with an
46
+ // accessible name, so assistive tech exposes it as a determinate progressbar with its current value.
47
+ class PlaceholderWidget extends WidgetType {
48
+ constructor(readonly data: PlaceholderData) {
49
+ super();
50
+ }
51
+
52
+ eq(other: WidgetType): boolean {
53
+ return (
54
+ other instanceof PlaceholderWidget &&
55
+ other.data.id === this.data.id &&
56
+ other.data.url === this.data.url &&
57
+ other.data.fraction === this.data.fraction
58
+ );
59
+ }
60
+
61
+ toDOM(): HTMLElement {
62
+ const wrap = document.createElement('span');
63
+ wrap.className = 'cm-cairn-media-placeholder';
64
+
65
+ const img = document.createElement('img');
66
+ img.className = 'cm-cairn-media-placeholder-thumb';
67
+ img.src = this.data.url;
68
+ img.alt = '';
69
+ img.setAttribute('aria-hidden', 'true');
70
+ wrap.appendChild(img);
71
+
72
+ // A real <progress> with an accessible name: assistive tech reports it as a progressbar with its
73
+ // determinate value, the honest indicator. No live region on the wrapper, since the widget is
74
+ // recreated each tick and could not host one reliably.
75
+ const bar = document.createElement('progress');
76
+ bar.className = 'cm-cairn-media-placeholder-bar';
77
+ bar.setAttribute('aria-label', 'Image upload progress');
78
+ bar.max = 1;
79
+ bar.value = this.data.fraction;
80
+ wrap.appendChild(bar);
81
+
82
+ return wrap;
83
+ }
84
+
85
+ ignoreEvent(): boolean {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /** The active placeholders as a decoration set plus the per-id position map, so a resolve can find
91
+ * the mapped position to insert at and the field can rebuild after a position shift. */
92
+ interface PlaceholderState {
93
+ set: DecorationSet;
94
+ // Each active placeholder's current data and its mapped document position.
95
+ items: Map<number, { data: PlaceholderData; pos: number }>;
96
+ }
97
+
98
+ function buildSet(items: Map<number, { data: PlaceholderData; pos: number }>): DecorationSet {
99
+ // Sort by position so the decoration set receives ascending ranges (RangeSet requires it).
100
+ const sorted = [...items.values()].sort((a, b) => a.pos - b.pos);
101
+ return Decoration.set(
102
+ sorted.map((it) => Decoration.widget({ widget: new PlaceholderWidget(it.data), side: 1 }).range(it.pos)),
103
+ );
104
+ }
105
+
106
+ const placeholderField = StateField.define<PlaceholderState>({
107
+ create() {
108
+ return { set: Decoration.none, items: new Map() };
109
+ },
110
+ update(value, tr) {
111
+ // Map every active placeholder's position across the change, so concurrent typing before a
112
+ // placeholder shifts it rather than stranding it.
113
+ let items = value.items;
114
+ let changed = false;
115
+ if (tr.docChanged) {
116
+ const next = new Map<number, { data: PlaceholderData; pos: number }>();
117
+ for (const [id, it] of items) next.set(id, { data: it.data, pos: tr.changes.mapPos(it.pos, 1) });
118
+ items = next;
119
+ changed = true;
120
+ }
121
+
122
+ for (const e of tr.effects) {
123
+ if (e.is(addPlaceholder)) {
124
+ const next = new Map(items);
125
+ next.set(e.value.id, { data: { id: e.value.id, url: e.value.url, fraction: 0.1 }, pos: e.value.pos });
126
+ items = next;
127
+ changed = true;
128
+ } else if (e.is(setProgress)) {
129
+ const it = items.get(e.value.id);
130
+ if (it) {
131
+ const next = new Map(items);
132
+ next.set(e.value.id, { data: { ...it.data, fraction: e.value.fraction }, pos: it.pos });
133
+ items = next;
134
+ changed = true;
135
+ }
136
+ } else if (e.is(removePlaceholder)) {
137
+ if (items.has(e.value.id)) {
138
+ const next = new Map(items);
139
+ next.delete(e.value.id);
140
+ items = next;
141
+ changed = true;
142
+ }
143
+ }
144
+ }
145
+
146
+ if (!changed) return value;
147
+ return { set: buildSet(items), items };
148
+ },
149
+ provide: (f) => EditorView.decorations.from(f, (v) => v.set),
150
+ });
151
+
152
+ /** The seam the host drives: begin lands a placeholder and returns its id; progress moves its bar;
153
+ * resolveTo swaps it for the committed image text; cancel removes it leaving the source untouched.
154
+ * Mirrors the register-callback idiom MarkdownEditor uses for its other editor ops. */
155
+ export interface ImagePlaceholderApi {
156
+ /** Land an optimistic placeholder at the current caret from a local object URL; returns its id. */
157
+ begin(objectUrl: string): number;
158
+ /** Update a placeholder's determinate progress bar to the given 0..1 fraction. */
159
+ progress(id: number, fraction: number): void;
160
+ /** Swap the placeholder for the committed `![alt](media:ref)` text in one transaction. */
161
+ resolveTo(id: number, alt: string, ref: string): void;
162
+ /** Remove the placeholder, leaving the source exactly as it was (the failure/expiry path). */
163
+ cancel(id: number): void;
164
+ }
165
+
166
+ // A module-level id counter. Browser app code, so a monotone counter is the simplest stable id; it
167
+ // never crosses a process boundary, so collision is not a concern.
168
+ let nextId = 1;
169
+
170
+ /**
171
+ * The placeholder extension: the StateField holding the active placeholders, their decorations, and
172
+ * the position-mapping across doc changes. The host adds it to the initial editor state, then builds
173
+ * the driving api with imagePlaceholderApi once the view exists.
174
+ */
175
+ export function cairnImagePlaceholders(): Extension {
176
+ return placeholderField;
177
+ }
178
+
179
+ /**
180
+ * Build the api that drives the placeholders against one editor view. The host registers it through
181
+ * registerImagePlaceholders; the insert popover calls begin, progress, resolveTo, and cancel.
182
+ */
183
+ export function imagePlaceholderApi(view: EditorView): ImagePlaceholderApi {
184
+ const api: ImagePlaceholderApi = {
185
+ begin(objectUrl) {
186
+ const id = nextId++;
187
+ const pos = view.state.selection.main.head;
188
+ view.dispatch({ effects: addPlaceholder.of({ id, pos, url: objectUrl }) });
189
+ return id;
190
+ },
191
+ progress(id, fraction) {
192
+ view.dispatch({ effects: setProgress.of({ id, fraction }) });
193
+ },
194
+ resolveTo(id, alt, ref) {
195
+ const it = view.state.field(placeholderField).items.get(id);
196
+ if (!it) return;
197
+ // Insert the committed text at the placeholder's mapped position, and remove the placeholder,
198
+ // in ONE transaction: the surface never shows a frame with neither the placeholder nor the
199
+ // text. insertImageFormat over an empty doc yields the bare `![alt](media:ref)` token, escaping
200
+ // a bracket in the alt the same way every other inline image insert does.
201
+ const token = insertImageFormat('', 0, 0, alt, ref).doc;
202
+ view.dispatch({
203
+ changes: { from: it.pos, insert: token },
204
+ effects: removePlaceholder.of({ id }),
205
+ selection: { anchor: it.pos + token.length },
206
+ });
207
+ },
208
+ cancel(id) {
209
+ view.dispatch({ effects: removePlaceholder.of({ id }) });
210
+ },
211
+ };
212
+ return api;
213
+ }
@@ -6,6 +6,7 @@ export { default as LoginPage } from './LoginPage.svelte';
6
6
  export { default as ConfirmPage } from './ConfirmPage.svelte';
7
7
  export { default as CsrfField } from './CsrfField.svelte';
8
8
  export { default as ConceptList } from './ConceptList.svelte';
9
+ export { default as CairnMediaLibrary } from './CairnMediaLibrary.svelte';
9
10
  export { default as EditPage } from './EditPage.svelte';
10
11
  export { default as ManageEditors } from './ManageEditors.svelte';
11
12
  export { default as MarkdownEditor } from './MarkdownEditor.svelte';
@@ -17,6 +17,17 @@ const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
17
17
  // never opens a real container.
18
18
  const CODE_FENCE = /^\s{0,3}(`{3,}|~{3,})/;
19
19
 
20
+ /**
21
+ * The directive name from a container opener line (`:::callout{...}` -> `callout`,
22
+ * `::::cta[Book]` -> `cta`), or null when the line is not a named container opener. Reads the
23
+ * same FENCE match the scan uses: group 2 is the name, empty on a bare closer, so a closer or a
24
+ * non-fence line returns null.
25
+ */
26
+ export function directiveOpenerName(line: string): string | null {
27
+ const m = FENCE.exec(line);
28
+ return m && m[2] ? m[2] : null;
29
+ }
30
+
20
31
  /** Classify a whole line as a container fence, a leaf directive, or neither. */
21
32
  export function directiveLineKind(line: string): 'fence' | 'leaf' | null {
22
33
  if (FENCE.test(line)) return 'fence';
@@ -150,6 +161,52 @@ export function containerRanges(scan: FenceScan): ContainerRange[] {
150
161
  return out;
151
162
  }
152
163
 
164
+ /** The closed placement-role set for the reserved `figure` directive, mirroring remark-figure.ts.
165
+ * A class outside this set is the measure default, never passed through as a role. */
166
+ const FIGURE_ROLES = new Set(['center', 'wide', 'full']);
167
+
168
+ // The directive `{attrs}` brace, and the `.class` shorthands inside it. mdast-util-directive folds
169
+ // every `.class` into a space-joined node.attributes.class, and remark-figure honors a role only when
170
+ // that whole value is exactly one closed-set name. The chip reads the opener line directly (the
171
+ // editor has no mdast), so it collects all class shorthands and applies the same exactly-one rule, so
172
+ // a multi-class brace reads as the measure default on the chip exactly as it renders.
173
+ const ATTR_BRACE = /\{([^}]*)\}/;
174
+ const CLASS_SHORTHAND = /\.([\w-]+)/g;
175
+ const CLASS_ATTR = /class\s*=\s*"([^"]*)"/;
176
+
177
+ /** The figure placement role for a media token sitting on `lineIndex`, derived from the editor's
178
+ * line scan without a remark parse (the chip rebuild runs on every doc and viewport change).
179
+ *
180
+ * Returns the closed-set role (`center`/`wide`/`full`) when the innermost container holding the
181
+ * line is a `:::figure` carrying exactly that one class, `'figure'` for a figure with no role (the
182
+ * measure default), an out-of-set class, or more than one class, and null when the line sits in no
183
+ * container or in a non-figure one. A bare media token earns no role pill (the no-hidden-state rule:
184
+ * the visible decoration and the source agree, including the multi-class case remark-figure ignores).
185
+ * Robust to a half-typed or unpaired fence, since {@link caretContainerRange} already disowns
186
+ * fence-shaped lines inside code blocks and runs an unclosed container to the end.
187
+ */
188
+ export function figureRoleAtLine(
189
+ scan: FenceScan,
190
+ lines: string[],
191
+ lineIndex: number,
192
+ ): 'center' | 'wide' | 'full' | 'figure' | null {
193
+ const container = caretContainerRange(scan, lineIndex);
194
+ if (!container) return null;
195
+ const opener = lines[container.fromLine] ?? '';
196
+ if (directiveOpenerName(opener) !== 'figure') return null;
197
+ const brace = ATTR_BRACE.exec(opener)?.[1] ?? '';
198
+ // mdast folds both the `.class` shorthand and an explicit `class="a b"` into one class value, so
199
+ // collect from both to mirror what remark-figure reads off node.attributes.class.
200
+ const classes = [...brace.matchAll(CLASS_SHORTHAND)].map((m) => m[1]);
201
+ const explicit = CLASS_ATTR.exec(brace)?.[1];
202
+ if (explicit) classes.push(...explicit.split(/\s+/).filter(Boolean));
203
+ // remark-figure keeps the role only when the whole class value is one closed-set name; a multi-class
204
+ // or out-of-set brace renders as the measure default, so the pill reads `figure` to match.
205
+ return classes.length === 1 && FIGURE_ROLES.has(classes[0])
206
+ ? (classes[0] as 'center' | 'wide' | 'full')
207
+ : 'figure';
208
+ }
209
+
153
210
  /** One span of a fence line, in line-local offsets: machinery (`mark`) or meaning (`label`). */
154
211
  export interface FenceToken {
155
212
  from: number;
@@ -6,9 +6,12 @@
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
- import type { Link } from 'mdast';
11
+ import type { Image, Link, Root, RootContent } from 'mdast';
12
+ import type { ContainerDirective } from 'mdast-util-directive';
11
13
  import { escapeLinkText } from '../content/links.js';
14
+ import { parseMediaToken } from '../media/reference.js';
12
15
 
13
16
  export type FormatKind =
14
17
  | 'bold'
@@ -163,6 +166,55 @@ export function insertInlineLink(doc: string, from: number, to: number, href: st
163
166
  return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: end, to: end };
164
167
  }
165
168
 
169
+ /**
170
+ * Insert an inline markdown image at the selection. The committed form is `![alt](ref)` where `ref`
171
+ * is the full `media:slug.hash` token. The alt is escaped the way an inline link's title is (the `[`
172
+ * and `]` an author types must not break the image syntax); a selection is replaced rather than
173
+ * wrapped, since an image carries no display text to wrap. The cursor collapses just after the
174
+ * inserted text, and no surrounding blank lines are added, since an image is inline. Pure, so the
175
+ * editor dispatches the result.
176
+ */
177
+ export function insertImage(doc: string, from: number, to: number, alt: string, ref: string): FormatResult {
178
+ const inserted = `![${escapeLinkText(alt)}](${ref})`;
179
+ const end = from + inserted.length;
180
+ return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: end, to: end };
181
+ }
182
+
183
+ /** One media image whose alt is empty, located by its source offsets and parsed to its ref parts. */
184
+ export interface MediaImageNeedingAlt {
185
+ from: number;
186
+ to: number;
187
+ ref: string;
188
+ slug: string;
189
+ hash: string;
190
+ }
191
+
192
+ /**
193
+ * Scan a markdown body for media images that carry no alt text, the publish-time accessibility debt
194
+ * the edit page counts. The document is parsed with the same remark pipeline unwrapCairnLink uses,
195
+ * so the two agree on what an image is. Each `image` node whose url is a valid `media:` reference and
196
+ * whose alt is empty or whitespace-only is returned with its source offsets and parsed slug and hash.
197
+ * Parsing (not a raw regex) means a `![](media:x)` written inside a code span or fence is not an
198
+ * image node and is correctly ignored, as is an alt-bearing media image and any non-media image (an
199
+ * http or cairn: url). Pure and node-safe, so the edit page derives the live count without a browser.
200
+ * The bare `media:<hash>` form yields an empty slug.
201
+ */
202
+ export function findMediaImagesNeedingAlt(doc: string): MediaImageNeedingAlt[] {
203
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
204
+ const hits: MediaImageNeedingAlt[] = [];
205
+ visit(tree, 'image', (node: Image) => {
206
+ const ref = parseMediaToken(node.url);
207
+ if (!ref) return;
208
+ if ((node.alt ?? '').trim() !== '') return;
209
+ const from = node.position?.start?.offset;
210
+ const to = node.position?.end?.offset;
211
+ if (from == null || to == null) return;
212
+ hits.push({ from, to, ref: node.url, slug: ref.slug ?? '', hash: ref.hash });
213
+ });
214
+ hits.sort((a, b) => a.from - b.from);
215
+ return hits;
216
+ }
217
+
166
218
  /** Concatenate a link node's text-child values. The parser has already unescaped them, so a source
167
219
  * `Notes \[draft\]` yields `Notes [draft]`. Used instead of mdast-util-to-string, which is not a
168
220
  * direct dependency. Non-text children (a nested emphasis, say) contribute no value, which is fine
@@ -197,3 +249,257 @@ export function unwrapCairnLink(doc: string, href: string): string {
197
249
  }
198
250
  return out;
199
251
  }
252
+
253
+ /** The closed placement role set the figure render step honors. A role outside it is the measure
254
+ * default (null), so the control never writes one. Mirrors the set in render/remark-figure.ts. */
255
+ export type FigureRole = 'center' | 'wide' | 'full';
256
+ const FIGURE_ROLES = new Set<string>(['center', 'wide', 'full']);
257
+
258
+ /**
259
+ * The media image at a caret, with the enclosing `:::figure` block when there is one. `imageFrom`
260
+ * and `imageTo` are the EXACT source offsets of the inner `![alt](media:slug.hash)` token, so a
261
+ * transform reuses the token byte-for-byte and never reserializes it (open risk 3). `figure` is null
262
+ * for a bare image (not in a figure); otherwise it carries the figure BLOCK's source offsets, the raw
263
+ * caption source the author wrote (inline markdown preserved, the directive-fence escape stripped),
264
+ * and the placement role (or null for the measure default).
265
+ */
266
+ export interface FigureAtImage {
267
+ /** The start offset of the inner `![...](media:...)` token. */
268
+ imageFrom: number;
269
+ /** The end offset of the inner `![...](media:...)` token. */
270
+ imageTo: number;
271
+ /** The enclosing figure, or null when the image is bare. */
272
+ figure: {
273
+ /** The figure block's start offset (the `:::figure` opener). */
274
+ from: number;
275
+ /** The figure block's end offset (just past the closing `:::`). */
276
+ to: number;
277
+ /** The raw caption source, inline markdown preserved; empty when the figure has no caption. */
278
+ caption: string;
279
+ /** The placement role, or null for the measure default. */
280
+ role: FigureRole | null;
281
+ } | null;
282
+ }
283
+
284
+ /** Parse a doc with the figure-aware pipeline (the render step's grammar), so the editor transforms
285
+ * agree with what renders. Container directives need remark-directive on top of the markdown base. */
286
+ function parseFigureDoc(doc: string): Root {
287
+ return unified().use(remarkParse).use(remarkGfm).use(remarkDirective).parse(doc) as Root;
288
+ }
289
+
290
+ /** Find the media `image` node whose source range contains `pos`, or whose enclosing figure contains
291
+ * `pos`, along with its enclosing `figure` directive when there is one. Returns null when `pos` is
292
+ * not on a media image nor inside a figure that wraps one. */
293
+ function locateMediaImage(
294
+ tree: Root,
295
+ pos: number,
296
+ ): { image: Image; figure: ContainerDirective | null } | null {
297
+ let bareHit: { image: Image; figure: ContainerDirective | null } | null = null;
298
+ let figureHit: { image: Image; figure: ContainerDirective } | null = null;
299
+ // Track the figure ancestor while visiting; unist-util-visit hands the ancestors array last.
300
+ visit(tree, 'image', (node: Image, _index, _parent) => {
301
+ if (!parseMediaToken(node.url)) return;
302
+ const from = node.position?.start?.offset;
303
+ const to = node.position?.end?.offset;
304
+ if (from == null || to == null) return;
305
+ const figure = enclosingFigure(tree, node);
306
+ if (pos >= from && pos <= to) {
307
+ if (figure) figureHit = { image: node, figure };
308
+ else if (!bareHit) bareHit = { image: node, figure: null };
309
+ return;
310
+ }
311
+ // The caret can sit in the caption, off the image token; a media image inside a figure whose
312
+ // block range contains pos still counts as "at" that figure.
313
+ if (figure) {
314
+ const f0 = figure.position?.start?.offset;
315
+ const f1 = figure.position?.end?.offset;
316
+ if (f0 != null && f1 != null && pos >= f0 && pos <= f1 && !figureHit) {
317
+ figureHit = { image: node, figure };
318
+ }
319
+ }
320
+ });
321
+ // A figure hit (the caret on the image or anywhere in its block) wins over a bare hit.
322
+ return figureHit ?? bareHit;
323
+ }
324
+
325
+ /** The `figure`-named container directive that encloses `node`, or null. Walks the tree to find the
326
+ * ancestor, since unist-util-visit's per-call ancestors are not retained across the traversal. */
327
+ function enclosingFigure(tree: Root, target: Image): ContainerDirective | null {
328
+ let found: ContainerDirective | null = null;
329
+ visit(tree, 'containerDirective', (dir: ContainerDirective) => {
330
+ if (dir.name !== 'figure') return;
331
+ let holds = false;
332
+ visit(dir, 'image', (img: Image) => {
333
+ if (img === target) holds = true;
334
+ });
335
+ if (holds) found = dir;
336
+ });
337
+ return found;
338
+ }
339
+
340
+ /** Strip one leading backslash sitting immediately before a colon, the inverse of the fence-escape
341
+ * wrapImageInFigure/updateFigure apply, so a caption that began with a directive-opening colon run
342
+ * round-trips to the author's original text. */
343
+ function unescapeCaption(raw: string): string {
344
+ return raw.replace(/^\\(?=:)/, '');
345
+ }
346
+
347
+ /** Collapse a raw caption source span to the single-line value the control edits: internal newlines
348
+ * to single spaces, trimmed, with the leading-colon fence escape stripped. */
349
+ function finishCaption(raw: string): string {
350
+ return unescapeCaption(raw.replace(/\s*\n\s*/g, ' ').trim());
351
+ }
352
+
353
+ /** Read the raw caption source from a figure directive, mirroring the render step's caption: the first
354
+ * text-bearing content after the image. The render step (remark-figure.ts) handles both caption
355
+ * forms, so the read must too. In the no-blank-line form the caption shares the image's paragraph,
356
+ * trailing the token, so it is read from the token end to that block's end; in the blank-line form it
357
+ * is the first text-bearing block after the image's paragraph. Only the first such content is the
358
+ * caption (a later block is a stray paragraph the render leaves outside the figcaption). Empty when
359
+ * the figure has no caption. */
360
+ function readCaption(doc: string, figure: ContainerDirective, image: Image): string {
361
+ const imageStart = image.position?.start?.offset;
362
+ const imageEnd = image.position?.end?.offset;
363
+ if (imageStart == null || imageEnd == null) return '';
364
+ // The figure's direct child that holds the image (its paragraph).
365
+ const imageBlock = figure.children.find((child) => {
366
+ const start = child.position?.start?.offset;
367
+ const end = child.position?.end?.offset;
368
+ return start != null && end != null && start <= imageStart && end >= imageEnd;
369
+ });
370
+ // No-blank-line form: caption text trails the token inside the image's own paragraph.
371
+ const blockEnd = imageBlock?.position?.end?.offset;
372
+ if (blockEnd != null && doc.slice(imageEnd, blockEnd).trim() !== '') {
373
+ return finishCaption(doc.slice(imageEnd, blockEnd));
374
+ }
375
+ // Blank-line form: the first text-bearing block after the image's paragraph.
376
+ const afterEnd = blockEnd ?? imageEnd;
377
+ for (const child of figure.children) {
378
+ const start = child.position?.start?.offset;
379
+ const end = child.position?.end?.offset;
380
+ if (start == null || end == null) continue;
381
+ if (start < afterEnd) continue;
382
+ if (!blockHasText(child)) continue;
383
+ return finishCaption(doc.slice(start, end));
384
+ }
385
+ return '';
386
+ }
387
+
388
+ /** Whether a block's subtree carries any non-whitespace text, the caption-candidate test the render
389
+ * step uses (a bare image paragraph has no text node, so it is never read as a caption). */
390
+ function blockHasText(node: RootContent): boolean {
391
+ let found = false;
392
+ visit(node, 'text', (text) => {
393
+ if (text.value.trim() !== '') found = true;
394
+ });
395
+ return found;
396
+ }
397
+
398
+ /**
399
+ * Inspect the media image at caret position `pos`. Returns the image's exact token offsets plus the
400
+ * enclosing `:::figure` block (its range, raw caption, and role) when the image is wrapped, or
401
+ * `figure: null` when it is bare. Returns null when `pos` is not on or in a media image. The parse
402
+ * uses the figure-aware pipeline, so this agrees with what remarkFigure renders. Pure and node-safe.
403
+ */
404
+ export function figureAtImage(doc: string, pos: number): FigureAtImage | null {
405
+ const tree = parseFigureDoc(doc);
406
+ const hit = locateMediaImage(tree, pos);
407
+ if (!hit) return null;
408
+ const imageFrom = hit.image.position?.start?.offset;
409
+ const imageTo = hit.image.position?.end?.offset;
410
+ if (imageFrom == null || imageTo == null) return null;
411
+ if (!hit.figure) return { imageFrom, imageTo, figure: null };
412
+ const dir = hit.figure;
413
+ const from = dir.position?.start?.offset;
414
+ const to = dir.position?.end?.offset;
415
+ if (from == null || to == null) return { imageFrom, imageTo, figure: null };
416
+ const className = dir.attributes?.class ?? undefined;
417
+ const role = className && FIGURE_ROLES.has(className) ? (className as FigureRole) : null;
418
+ return { imageFrom, imageTo, figure: { from, to, caption: readCaption(doc, dir, hit.image), role } };
419
+ }
420
+
421
+ /** Sanitize a caption into a single safe body line: collapse internal newlines to single spaces,
422
+ * trim, and neutralize ONLY the directive-fence hazard (a leading colon would open a directive at
423
+ * line start) by prefixing one backslash. The author's inline markdown is preserved otherwise, so
424
+ * emphasis and links survive. figureAtImage strips the backslash on read for a clean round-trip. */
425
+ function sanitizeCaption(caption: string): string {
426
+ const line = caption.replace(/\s*\n\s*/g, ' ').trim();
427
+ return line.startsWith(':') ? '\\' + line : line;
428
+ }
429
+
430
+ /** Build the canonical figure block source: the opener (with the role brace only for a non-null
431
+ * role), the image token verbatim on its own line, then a blank line and the sanitized caption when
432
+ * the caption is non-empty, and the closing fence. This is the blank-line form remarkFigure reads as
433
+ * its primary path, and it reads cleanly when hand-edited. */
434
+ function buildFigureBlock(imageSrc: string, caption: string, role: FigureRole | null): string {
435
+ const opener = role ? `:::figure{.${role}}` : ':::figure';
436
+ const cap = sanitizeCaption(caption);
437
+ const body = cap ? `${imageSrc}\n\n${cap}` : imageSrc;
438
+ return `${opener}\n${body}\n:::`;
439
+ }
440
+
441
+ /**
442
+ * Wrap a bare media image in a `:::figure` block. The image token is reused EXACTLY from its source
443
+ * offsets and never reserialized (open risk 3: the atomic `media:` reference stays byte-identical).
444
+ * The block lands on its own lines, with a blank line before it (unless it starts the document) and
445
+ * after it, so it reads as a clean block even when the image sat inline in a paragraph. The selection
446
+ * collapses just past the inserted block.
447
+ */
448
+ export function wrapImageInFigure(
449
+ doc: string,
450
+ imageFrom: number,
451
+ imageTo: number,
452
+ caption: string,
453
+ role: FigureRole | null,
454
+ ): FormatResult {
455
+ const imageSrc = doc.slice(imageFrom, imageTo);
456
+ const block = buildFigureBlock(imageSrc, caption, role);
457
+ const before = doc.slice(0, imageFrom);
458
+ const after = doc.slice(imageTo);
459
+ // Ensure the block starts on its own line: a blank line before it unless it opens the doc or the
460
+ // text before it already ends with one. Trailing context gets a matching blank line.
461
+ const lead = before === '' ? '' : before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
462
+ const trail = after === '' ? '' : after.startsWith('\n\n') ? '' : after.startsWith('\n') ? '\n' : '\n\n';
463
+ const inserted = lead + block + trail;
464
+ const blockStart = imageFrom + lead.length;
465
+ const end = blockStart + block.length;
466
+ return { doc: before + inserted + after, from: end, to: end };
467
+ }
468
+
469
+ /** The inner image token of the figure at `figureRange.from`, sliced verbatim from the source so it
470
+ * is reused byte-for-byte (open risk 3). Empty when no media image is found there, which leaves the
471
+ * rebuild image-less rather than throwing. Shared by updateFigure and unwrapFigure. */
472
+ function figureImageSrc(doc: string, figureRange: { from: number; to: number }): string {
473
+ const info = figureAtImage(doc, figureRange.from);
474
+ return info ? doc.slice(info.imageFrom, info.imageTo) : '';
475
+ }
476
+
477
+ /**
478
+ * Rewrite an existing figure's caption and role in place. The inner image token is extracted from the
479
+ * current block and PRESERVED BYTE-FOR-BYTE (open risk 3); the block is rebuilt in the blank-line
480
+ * form with the new opener and caption. The selection collapses just past the rewritten block.
481
+ */
482
+ export function updateFigure(
483
+ doc: string,
484
+ figureRange: { from: number; to: number },
485
+ caption: string,
486
+ role: FigureRole | null,
487
+ ): FormatResult {
488
+ const block = buildFigureBlock(figureImageSrc(doc, figureRange), caption, role);
489
+ const end = figureRange.from + block.length;
490
+ return { doc: doc.slice(0, figureRange.from) + block + doc.slice(figureRange.to), from: end, to: end };
491
+ }
492
+
493
+ /**
494
+ * Unwrap a figure block back to its bare image line, dropping the caption and the directive fences.
495
+ * The inner image token is reused verbatim (open risk 3). The selection lands on the restored image
496
+ * so the author can act on it again.
497
+ */
498
+ export function unwrapFigure(doc: string, figureRange: { from: number; to: number }): FormatResult {
499
+ const imageSrc = figureImageSrc(doc, figureRange);
500
+ return {
501
+ doc: doc.slice(0, figureRange.from) + imageSrc + doc.slice(figureRange.to),
502
+ from: figureRange.from,
503
+ to: figureRange.from + imageSrc.length,
504
+ };
505
+ }