@glw907/cairn-cms 0.56.2 → 0.57.1

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 +134 -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 +949 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +348 -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 +578 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +75 -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 +22 -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 +64 -11
  47. package/dist/content/validate.js +31 -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 +77 -2
  102. package/dist/sveltekit/content-routes.js +470 -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 +949 -0
  116. package/src/lib/components/EditPage.svelte +348 -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 +578 -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 +20 -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 +80 -13
  138. package/src/lib/content/validate.ts +29 -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 +589 -12
  169. package/src/lib/sveltekit/csrf.ts +18 -0
  170. package/src/lib/sveltekit/guard.ts +12 -3
  171. package/src/lib/sveltekit/index.ts +6 -0
  172. package/src/lib/sveltekit/media-route.ts +158 -0
  173. package/src/lib/vite/index.ts +9 -2
@@ -0,0 +1,26 @@
1
+ import { EditorView } from '@codemirror/view';
2
+ import { type Extension } from '@codemirror/state';
3
+ /** The seam the host drives: begin lands a placeholder and returns its id; progress moves its bar;
4
+ * resolveTo swaps it for the committed image text; cancel removes it leaving the source untouched.
5
+ * Mirrors the register-callback idiom MarkdownEditor uses for its other editor ops. */
6
+ export interface ImagePlaceholderApi {
7
+ /** Land an optimistic placeholder at the current caret from a local object URL; returns its id. */
8
+ begin(objectUrl: string): number;
9
+ /** Update a placeholder's determinate progress bar to the given 0..1 fraction. */
10
+ progress(id: number, fraction: number): void;
11
+ /** Swap the placeholder for the committed `![alt](media:ref)` text in one transaction. */
12
+ resolveTo(id: number, alt: string, ref: string): void;
13
+ /** Remove the placeholder, leaving the source exactly as it was (the failure/expiry path). */
14
+ cancel(id: number): void;
15
+ }
16
+ /**
17
+ * The placeholder extension: the StateField holding the active placeholders, their decorations, and
18
+ * the position-mapping across doc changes. The host adds it to the initial editor state, then builds
19
+ * the driving api with imagePlaceholderApi once the view exists.
20
+ */
21
+ export declare function cairnImagePlaceholders(): Extension;
22
+ /**
23
+ * Build the api that drives the placeholders against one editor view. The host registers it through
24
+ * registerImagePlaceholders; the insert popover calls begin, progress, resolveTo, and cancel.
25
+ */
26
+ export declare function imagePlaceholderApi(view: EditorView): ImagePlaceholderApi;
@@ -0,0 +1,166 @@
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 { Decoration, EditorView, WidgetType, } from '@codemirror/view';
19
+ import { StateEffect, StateField } from '@codemirror/state';
20
+ import { insertImage as insertImageFormat } from './markdown-format.js';
21
+ // The effects that drive the placeholder field. add lands a placeholder at a position; set-progress
22
+ // updates its bar; remove takes it out. resolveTo and cancel both end in a remove (resolveTo pairs
23
+ // the remove with the text insert in its dispatch; cancel dispatches only the remove).
24
+ const addPlaceholder = StateEffect.define();
25
+ const setProgress = StateEffect.define();
26
+ const removePlaceholder = StateEffect.define();
27
+ // The widget: a small thumbnail from the local object URL plus a determinate progress bar, in the
28
+ // editor's accent visual language (the same accent tint the media chip uses). The wrapper carries no
29
+ // live region: the widget is recreated on every progress tick (CodeMirror replaces the decoration),
30
+ // so a live region on it could not reliably announce a change. The bar is a real <progress> with an
31
+ // accessible name, so assistive tech exposes it as a determinate progressbar with its current value.
32
+ class PlaceholderWidget extends WidgetType {
33
+ data;
34
+ constructor(data) {
35
+ super();
36
+ this.data = data;
37
+ }
38
+ eq(other) {
39
+ return (other instanceof PlaceholderWidget &&
40
+ other.data.id === this.data.id &&
41
+ other.data.url === this.data.url &&
42
+ other.data.fraction === this.data.fraction);
43
+ }
44
+ toDOM() {
45
+ const wrap = document.createElement('span');
46
+ wrap.className = 'cm-cairn-media-placeholder';
47
+ const img = document.createElement('img');
48
+ img.className = 'cm-cairn-media-placeholder-thumb';
49
+ img.src = this.data.url;
50
+ img.alt = '';
51
+ img.setAttribute('aria-hidden', 'true');
52
+ wrap.appendChild(img);
53
+ // A real <progress> with an accessible name: assistive tech reports it as a progressbar with its
54
+ // determinate value, the honest indicator. No live region on the wrapper, since the widget is
55
+ // recreated each tick and could not host one reliably.
56
+ const bar = document.createElement('progress');
57
+ bar.className = 'cm-cairn-media-placeholder-bar';
58
+ bar.setAttribute('aria-label', 'Image upload progress');
59
+ bar.max = 1;
60
+ bar.value = this.data.fraction;
61
+ wrap.appendChild(bar);
62
+ return wrap;
63
+ }
64
+ ignoreEvent() {
65
+ return false;
66
+ }
67
+ }
68
+ function buildSet(items) {
69
+ // Sort by position so the decoration set receives ascending ranges (RangeSet requires it).
70
+ const sorted = [...items.values()].sort((a, b) => a.pos - b.pos);
71
+ return Decoration.set(sorted.map((it) => Decoration.widget({ widget: new PlaceholderWidget(it.data), side: 1 }).range(it.pos)));
72
+ }
73
+ const placeholderField = StateField.define({
74
+ create() {
75
+ return { set: Decoration.none, items: new Map() };
76
+ },
77
+ update(value, tr) {
78
+ // Map every active placeholder's position across the change, so concurrent typing before a
79
+ // placeholder shifts it rather than stranding it.
80
+ let items = value.items;
81
+ let changed = false;
82
+ if (tr.docChanged) {
83
+ const next = new Map();
84
+ for (const [id, it] of items)
85
+ next.set(id, { data: it.data, pos: tr.changes.mapPos(it.pos, 1) });
86
+ items = next;
87
+ changed = true;
88
+ }
89
+ for (const e of tr.effects) {
90
+ if (e.is(addPlaceholder)) {
91
+ const next = new Map(items);
92
+ next.set(e.value.id, { data: { id: e.value.id, url: e.value.url, fraction: 0.1 }, pos: e.value.pos });
93
+ items = next;
94
+ changed = true;
95
+ }
96
+ else if (e.is(setProgress)) {
97
+ const it = items.get(e.value.id);
98
+ if (it) {
99
+ const next = new Map(items);
100
+ next.set(e.value.id, { data: { ...it.data, fraction: e.value.fraction }, pos: it.pos });
101
+ items = next;
102
+ changed = true;
103
+ }
104
+ }
105
+ else if (e.is(removePlaceholder)) {
106
+ if (items.has(e.value.id)) {
107
+ const next = new Map(items);
108
+ next.delete(e.value.id);
109
+ items = next;
110
+ changed = true;
111
+ }
112
+ }
113
+ }
114
+ if (!changed)
115
+ return value;
116
+ return { set: buildSet(items), items };
117
+ },
118
+ provide: (f) => EditorView.decorations.from(f, (v) => v.set),
119
+ });
120
+ // A module-level id counter. Browser app code, so a monotone counter is the simplest stable id; it
121
+ // never crosses a process boundary, so collision is not a concern.
122
+ let nextId = 1;
123
+ /**
124
+ * The placeholder extension: the StateField holding the active placeholders, their decorations, and
125
+ * the position-mapping across doc changes. The host adds it to the initial editor state, then builds
126
+ * the driving api with imagePlaceholderApi once the view exists.
127
+ */
128
+ export function cairnImagePlaceholders() {
129
+ return placeholderField;
130
+ }
131
+ /**
132
+ * Build the api that drives the placeholders against one editor view. The host registers it through
133
+ * registerImagePlaceholders; the insert popover calls begin, progress, resolveTo, and cancel.
134
+ */
135
+ export function imagePlaceholderApi(view) {
136
+ const api = {
137
+ begin(objectUrl) {
138
+ const id = nextId++;
139
+ const pos = view.state.selection.main.head;
140
+ view.dispatch({ effects: addPlaceholder.of({ id, pos, url: objectUrl }) });
141
+ return id;
142
+ },
143
+ progress(id, fraction) {
144
+ view.dispatch({ effects: setProgress.of({ id, fraction }) });
145
+ },
146
+ resolveTo(id, alt, ref) {
147
+ const it = view.state.field(placeholderField).items.get(id);
148
+ if (!it)
149
+ return;
150
+ // Insert the committed text at the placeholder's mapped position, and remove the placeholder,
151
+ // in ONE transaction: the surface never shows a frame with neither the placeholder nor the
152
+ // text. insertImageFormat over an empty doc yields the bare `![alt](media:ref)` token, escaping
153
+ // a bracket in the alt the same way every other inline image insert does.
154
+ const token = insertImageFormat('', 0, 0, alt, ref).doc;
155
+ view.dispatch({
156
+ changes: { from: it.pos, insert: token },
157
+ effects: removePlaceholder.of({ id }),
158
+ selection: { anchor: it.pos + token.length },
159
+ });
160
+ },
161
+ cancel(id) {
162
+ view.dispatch({ effects: removePlaceholder.of({ id }) });
163
+ },
164
+ };
165
+ return api;
166
+ }
@@ -4,6 +4,7 @@ export { default as LoginPage } from './LoginPage.svelte';
4
4
  export { default as ConfirmPage } from './ConfirmPage.svelte';
5
5
  export { default as CsrfField } from './CsrfField.svelte';
6
6
  export { default as ConceptList } from './ConceptList.svelte';
7
+ export { default as CairnMediaLibrary } from './CairnMediaLibrary.svelte';
7
8
  export { default as EditPage } from './EditPage.svelte';
8
9
  export { default as ManageEditors } from './ManageEditors.svelte';
9
10
  export { default as MarkdownEditor } from './MarkdownEditor.svelte';
@@ -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';
@@ -56,6 +56,18 @@ export declare function caretContainerRange(scan: FenceScan, caretLine: number):
56
56
  * example can neither open nor close a range. This is the sole source of fold ranges.
57
57
  */
58
58
  export declare function containerRanges(scan: FenceScan): ContainerRange[];
59
+ /** The figure placement role for a media token sitting on `lineIndex`, derived from the editor's
60
+ * line scan without a remark parse (the chip rebuild runs on every doc and viewport change).
61
+ *
62
+ * Returns the closed-set role (`center`/`wide`/`full`) when the innermost container holding the
63
+ * line is a `:::figure` carrying exactly that one class, `'figure'` for a figure with no role (the
64
+ * measure default), an out-of-set class, or more than one class, and null when the line sits in no
65
+ * container or in a non-figure one. A bare media token earns no role pill (the no-hidden-state rule:
66
+ * the visible decoration and the source agree, including the multi-class case remark-figure ignores).
67
+ * Robust to a half-typed or unpaired fence, since {@link caretContainerRange} already disowns
68
+ * fence-shaped lines inside code blocks and runs an unclosed container to the end.
69
+ */
70
+ export declare function figureRoleAtLine(scan: FenceScan, lines: string[], lineIndex: number): 'center' | 'wide' | 'full' | 'figure' | null;
59
71
  /** One span of a fence line, in line-local offsets: machinery (`mark`) or meaning (`label`). */
60
72
  export interface FenceToken {
61
73
  from: number;
@@ -145,6 +145,48 @@ export function containerRanges(scan) {
145
145
  }
146
146
  return out;
147
147
  }
148
+ /** The closed placement-role set for the reserved `figure` directive, mirroring remark-figure.ts.
149
+ * A class outside this set is the measure default, never passed through as a role. */
150
+ const FIGURE_ROLES = new Set(['center', 'wide', 'full']);
151
+ // The directive `{attrs}` brace, and the `.class` shorthands inside it. mdast-util-directive folds
152
+ // every `.class` into a space-joined node.attributes.class, and remark-figure honors a role only when
153
+ // that whole value is exactly one closed-set name. The chip reads the opener line directly (the
154
+ // editor has no mdast), so it collects all class shorthands and applies the same exactly-one rule, so
155
+ // a multi-class brace reads as the measure default on the chip exactly as it renders.
156
+ const ATTR_BRACE = /\{([^}]*)\}/;
157
+ const CLASS_SHORTHAND = /\.([\w-]+)/g;
158
+ const CLASS_ATTR = /class\s*=\s*"([^"]*)"/;
159
+ /** The figure placement role for a media token sitting on `lineIndex`, derived from the editor's
160
+ * line scan without a remark parse (the chip rebuild runs on every doc and viewport change).
161
+ *
162
+ * Returns the closed-set role (`center`/`wide`/`full`) when the innermost container holding the
163
+ * line is a `:::figure` carrying exactly that one class, `'figure'` for a figure with no role (the
164
+ * measure default), an out-of-set class, or more than one class, and null when the line sits in no
165
+ * container or in a non-figure one. A bare media token earns no role pill (the no-hidden-state rule:
166
+ * the visible decoration and the source agree, including the multi-class case remark-figure ignores).
167
+ * Robust to a half-typed or unpaired fence, since {@link caretContainerRange} already disowns
168
+ * fence-shaped lines inside code blocks and runs an unclosed container to the end.
169
+ */
170
+ export function figureRoleAtLine(scan, lines, lineIndex) {
171
+ const container = caretContainerRange(scan, lineIndex);
172
+ if (!container)
173
+ return null;
174
+ const opener = lines[container.fromLine] ?? '';
175
+ if (directiveOpenerName(opener) !== 'figure')
176
+ return null;
177
+ const brace = ATTR_BRACE.exec(opener)?.[1] ?? '';
178
+ // mdast folds both the `.class` shorthand and an explicit `class="a b"` into one class value, so
179
+ // collect from both to mirror what remark-figure reads off node.attributes.class.
180
+ const classes = [...brace.matchAll(CLASS_SHORTHAND)].map((m) => m[1]);
181
+ const explicit = CLASS_ATTR.exec(brace)?.[1];
182
+ if (explicit)
183
+ classes.push(...explicit.split(/\s+/).filter(Boolean));
184
+ // remark-figure keeps the role only when the whole class value is one closed-set name; a multi-class
185
+ // or out-of-set brace renders as the measure default, so the pill reads `figure` to match.
186
+ return classes.length === 1 && FIGURE_ROLES.has(classes[0])
187
+ ? classes[0]
188
+ : 'figure';
189
+ }
148
190
  /**
149
191
  * Split a fence line into machinery and meaning. The colon run, the label's brackets, and the
150
192
  * whole {attrs} group are machinery; the directive name and the label text are meaning, the
@@ -12,6 +12,34 @@ export declare function applyMarkdownFormat(doc: string, from: number, to: numbe
12
12
  * blank lines, since a link is inline. Pure, so the editor dispatches the result.
13
13
  */
14
14
  export declare function insertInlineLink(doc: string, from: number, to: number, href: string, title: string): FormatResult;
15
+ /**
16
+ * Insert an inline markdown image at the selection. The committed form is `![alt](ref)` where `ref`
17
+ * is the full `media:slug.hash` token. The alt is escaped the way an inline link's title is (the `[`
18
+ * and `]` an author types must not break the image syntax); a selection is replaced rather than
19
+ * wrapped, since an image carries no display text to wrap. The cursor collapses just after the
20
+ * inserted text, and no surrounding blank lines are added, since an image is inline. Pure, so the
21
+ * editor dispatches the result.
22
+ */
23
+ export declare function insertImage(doc: string, from: number, to: number, alt: string, ref: string): FormatResult;
24
+ /** One media image whose alt is empty, located by its source offsets and parsed to its ref parts. */
25
+ export interface MediaImageNeedingAlt {
26
+ from: number;
27
+ to: number;
28
+ ref: string;
29
+ slug: string;
30
+ hash: string;
31
+ }
32
+ /**
33
+ * Scan a markdown body for media images that carry no alt text, the publish-time accessibility debt
34
+ * the edit page counts. The document is parsed with the same remark pipeline unwrapCairnLink uses,
35
+ * so the two agree on what an image is. Each `image` node whose url is a valid `media:` reference and
36
+ * whose alt is empty or whitespace-only is returned with its source offsets and parsed slug and hash.
37
+ * Parsing (not a raw regex) means a `![](media:x)` written inside a code span or fence is not an
38
+ * image node and is correctly ignored, as is an alt-bearing media image and any non-media image (an
39
+ * http or cairn: url). Pure and node-safe, so the edit page derives the live count without a browser.
40
+ * The bare `media:<hash>` form yields an empty slug.
41
+ */
42
+ export declare function findMediaImagesNeedingAlt(doc: string): MediaImageNeedingAlt[];
15
43
  /**
16
44
  * Unwrap every cairn: link whose href is exactly `href`, replacing it with its plain display text.
17
45
  * The save guard's one-click fix calls this to drop a broken link while keeping the words. The
@@ -22,3 +50,64 @@ export declare function insertInlineLink(doc: string, from: number, to: number,
22
50
  * is left in place.
23
51
  */
24
52
  export declare function unwrapCairnLink(doc: string, href: string): string;
53
+ /** The closed placement role set the figure render step honors. A role outside it is the measure
54
+ * default (null), so the control never writes one. Mirrors the set in render/remark-figure.ts. */
55
+ export type FigureRole = 'center' | 'wide' | 'full';
56
+ /**
57
+ * The media image at a caret, with the enclosing `:::figure` block when there is one. `imageFrom`
58
+ * and `imageTo` are the EXACT source offsets of the inner `![alt](media:slug.hash)` token, so a
59
+ * transform reuses the token byte-for-byte and never reserializes it (open risk 3). `figure` is null
60
+ * for a bare image (not in a figure); otherwise it carries the figure BLOCK's source offsets, the raw
61
+ * caption source the author wrote (inline markdown preserved, the directive-fence escape stripped),
62
+ * and the placement role (or null for the measure default).
63
+ */
64
+ export interface FigureAtImage {
65
+ /** The start offset of the inner `![...](media:...)` token. */
66
+ imageFrom: number;
67
+ /** The end offset of the inner `![...](media:...)` token. */
68
+ imageTo: number;
69
+ /** The enclosing figure, or null when the image is bare. */
70
+ figure: {
71
+ /** The figure block's start offset (the `:::figure` opener). */
72
+ from: number;
73
+ /** The figure block's end offset (just past the closing `:::`). */
74
+ to: number;
75
+ /** The raw caption source, inline markdown preserved; empty when the figure has no caption. */
76
+ caption: string;
77
+ /** The placement role, or null for the measure default. */
78
+ role: FigureRole | null;
79
+ } | null;
80
+ }
81
+ /**
82
+ * Inspect the media image at caret position `pos`. Returns the image's exact token offsets plus the
83
+ * enclosing `:::figure` block (its range, raw caption, and role) when the image is wrapped, or
84
+ * `figure: null` when it is bare. Returns null when `pos` is not on or in a media image. The parse
85
+ * uses the figure-aware pipeline, so this agrees with what remarkFigure renders. Pure and node-safe.
86
+ */
87
+ export declare function figureAtImage(doc: string, pos: number): FigureAtImage | null;
88
+ /**
89
+ * Wrap a bare media image in a `:::figure` block. The image token is reused EXACTLY from its source
90
+ * offsets and never reserialized (open risk 3: the atomic `media:` reference stays byte-identical).
91
+ * The block lands on its own lines, with a blank line before it (unless it starts the document) and
92
+ * after it, so it reads as a clean block even when the image sat inline in a paragraph. The selection
93
+ * collapses just past the inserted block.
94
+ */
95
+ export declare function wrapImageInFigure(doc: string, imageFrom: number, imageTo: number, caption: string, role: FigureRole | null): FormatResult;
96
+ /**
97
+ * Rewrite an existing figure's caption and role in place. The inner image token is extracted from the
98
+ * current block and PRESERVED BYTE-FOR-BYTE (open risk 3); the block is rebuilt in the blank-line
99
+ * form with the new opener and caption. The selection collapses just past the rewritten block.
100
+ */
101
+ export declare function updateFigure(doc: string, figureRange: {
102
+ from: number;
103
+ to: number;
104
+ }, caption: string, role: FigureRole | null): FormatResult;
105
+ /**
106
+ * Unwrap a figure block back to its bare image line, dropping the caption and the directive fences.
107
+ * The inner image token is reused verbatim (open risk 3). The selection lands on the restored image
108
+ * so the author can act on it again.
109
+ */
110
+ export declare function unwrapFigure(doc: string, figureRange: {
111
+ from: number;
112
+ to: number;
113
+ }): FormatResult;
@@ -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
+ }