@glw907/cairn-cms 0.60.0 → 0.60.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 (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/components/AdminLayout.svelte +130 -229
  3. package/dist/components/CairnAdmin.svelte +10 -42
  4. package/dist/components/CairnLogo.svelte +1 -6
  5. package/dist/components/CairnMediaLibrary.svelte +821 -1210
  6. package/dist/components/CairnTidySettings.svelte +192 -259
  7. package/dist/components/ComponentForm.svelte +110 -185
  8. package/dist/components/ComponentInsertDialog.svelte +163 -283
  9. package/dist/components/ConceptList.svelte +111 -191
  10. package/dist/components/ConfirmPage.svelte +5 -12
  11. package/dist/components/CsrfField.svelte +5 -11
  12. package/dist/components/DeleteDialog.svelte +15 -42
  13. package/dist/components/EditPage.svelte +665 -1166
  14. package/dist/components/EditorToolbar.svelte +108 -170
  15. package/dist/components/IconPicker.svelte +23 -53
  16. package/dist/components/LinkPicker.svelte +34 -58
  17. package/dist/components/LoginPage.svelte +14 -27
  18. package/dist/components/ManageEditors.svelte +3 -15
  19. package/dist/components/MarkdownEditor.svelte +689 -957
  20. package/dist/components/MarkdownHelpDialog.svelte +8 -12
  21. package/dist/components/MediaCaptureCard.svelte +18 -57
  22. package/dist/components/MediaFigureControl.svelte +32 -71
  23. package/dist/components/MediaHeroField.svelte +210 -329
  24. package/dist/components/MediaInsertPopover.svelte +156 -283
  25. package/dist/components/MediaPicker.svelte +67 -131
  26. package/dist/components/NavTree.svelte +46 -78
  27. package/dist/components/RenameDialog.svelte +16 -43
  28. package/dist/components/ShortcutsDialog.svelte +9 -13
  29. package/dist/components/ShortcutsGrid.svelte +1 -2
  30. package/dist/components/TidyReview.svelte +140 -248
  31. package/dist/components/WebLinkDialog.svelte +19 -40
  32. package/dist/components/cairn-admin.css +4 -0
  33. package/dist/components/spellcheck.d.ts +3 -1
  34. package/dist/components/spellcheck.js +14 -2
  35. package/dist/delivery/CairnHead.svelte +8 -11
  36. package/package.json +2 -2
  37. package/src/lib/components/spellcheck.ts +16 -2
@@ -16,1206 +16,705 @@ pending), and an overflow menu for Discard and Delete. One feedback strip under
16
16
  transient flashes, and the editor card's footer is the writing-environment strip: the word
17
17
  count, the Prose/Markup posture pair, the focus and typewriter toggles, and the Markdown help.
18
18
  -->
19
- <script lang="ts">
20
- import { flushSync, untrack, getContext } from 'svelte';
21
- import { beforeNavigate } from '$app/navigation';
22
- import { deserialize } from '$app/forms';
23
- import { page } from '$app/state';
24
- import BlocksIcon from '@lucide/svelte/icons/blocks';
25
- import SquarePenIcon from '@lucide/svelte/icons/square-pen';
26
- import LinkIcon from '@lucide/svelte/icons/link';
27
- import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
28
- import PanelRightIcon from '@lucide/svelte/icons/panel-right';
29
- import ImageIcon from '@lucide/svelte/icons/image';
30
- import { useTopbar } from './topbar-context.js';
31
- import CsrfField from './CsrfField.svelte';
32
- import MarkdownEditor from './MarkdownEditor.svelte';
33
- import EditorToolbar from './EditorToolbar.svelte';
34
- import ComponentInsertDialog, { insertableDefs, hasSchema } from './ComponentInsertDialog.svelte';
35
- import LinkPicker from './LinkPicker.svelte';
36
- import WebLinkDialog from './WebLinkDialog.svelte';
37
- import MediaInsertPopover from './MediaInsertPopover.svelte';
38
- import MediaHeroField from './MediaHeroField.svelte';
39
- import MediaFigureControl from './MediaFigureControl.svelte';
40
- import DeleteDialog from './DeleteDialog.svelte';
41
- import RenameDialog from './RenameDialog.svelte';
42
- import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
43
- import ShortcutsDialog from './ShortcutsDialog.svelte';
44
- import TidyReview from './TidyReview.svelte';
45
- import SparklesIcon from '@lucide/svelte/icons/sparkles';
46
- import { validateTidy, TIDY_REJECTION_MESSAGE } from './tidy-validate.js';
47
- import type { Change } from './tidy-diff.js';
48
- import { cairnLinkCompletionSource } from './link-completion.js';
49
- import {
50
- findMediaImagesNeedingAlt,
51
- unwrapCairnLink,
52
- unwrapFigure,
53
- updateFigure,
54
- wrapImageInFigure,
55
- type FigureAtImage,
56
- type FigureRole,
57
- type FormatKind,
58
- } from './markdown-format.js';
59
- import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
60
- import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
61
- import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
62
- import { parseComponent, componentRoundTripSafety } from '../render/component-grammar.js';
63
- import type { IconSet } from '../render/glyph.js';
64
- import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
65
- import type { TextareaField, TagsField, FreeTagsField, ImageValue } from '../content/types.js';
66
- import type { LinkResolve } from '../content/links.js';
67
- import { manifestLinkResolver } from '../content/manifest.js';
68
- import type { MediaResolve } from '../render/resolve-media.js';
69
- import { manifestMediaResolver } from '../render/resolve-media.js';
70
- import type { MediaEntry } from '../media/manifest.js';
71
- import { mediaLibraryEntry } from '../media/library-entry.js';
72
- import { CSRF_CONTEXT_KEY } from './csrf-context.js';
73
-
74
- interface Props {
75
- /** The edit load's data, plus the site name for the heading. */
76
- data: EditData & { siteName: string };
77
- /** The site's component registry, for the insert palette. */
78
- registry?: ComponentRegistry;
79
- /** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
80
- render?: (
81
- md: string,
82
- opts?: { stagger?: boolean; resolve?: LinkResolve; resolveMedia?: MediaResolve },
83
- ) => string | Promise<string>;
84
- /** The site's icon set, for the guided form's icon fields. */
85
- icons?: IconSet;
86
- /** The last content action's failure: the save guard's broken links, the delete guard's
87
- * inbound linkers, or a rename refusal, each carrying the shared `error` summary. */
88
- form?: ContentFormFailure | null;
89
- }
90
-
91
- let { data, registry, render, icons, form }: Props = $props();
92
-
93
- // The client-side tidy deadline (spec 2.1, Task 14): a slow call becomes a cancel/retry rather than a
94
- // hung review. Set above the action's own 30s Worker deadline so the server's retryable fail lands
95
- // first when the model is merely slow; this catches a stalled connection past that.
96
- const TIDY_CLIENT_TIMEOUT_MS = 45_000;
97
-
98
- // The topbar context portal (AdminLayout owns the holder). The desk snippet below carries the
99
- // document's status and action clusters; this effect registers it into the band on mount and
100
- // nulls it on teardown, so CairnAdmin's view switch (which unmounts EditPage) clears the band.
101
- // The holder is absent only when EditPage renders outside AdminLayout (it always renders inside
102
- // it in the app); the optional chaining keeps that case inert.
103
- const topbar = useTopbar();
104
- $effect(() => {
105
- if (!topbar) return;
106
- topbar.desk = desk;
107
- // Zen drops the band: AdminLayout reads this flag to remove the whole topbar element, so the
108
- // desk's clusters and AdminLayout's own chrome (the drawer toggle, the breadcrumb) all slide
109
- // away together. The effect tracks `zen`, so a toggle reaches the band live.
110
- topbar.zen = zen;
111
- return () => {
112
- topbar.desk = null;
113
- topbar.zen = false;
114
- };
115
- });
116
-
117
- // `body` is local editor state seeded once; it diverges as the user types. A blocked save returns
118
- // the author's edited markdown as form.body, so seed from that when present to keep the edits and
119
- // the broken link they were told to fix. On the success and delete-refused paths form carries no
120
- // body, so it falls back to the committed data.body. untrack() captures the initial value without
121
- // subscribing to future prop changes.
122
- let body = $state(untrack(() => form?.body ?? data.body));
123
- // True from the moment the save form submits until the navigation it triggers replaces the page,
124
- // so the Save button shows a calm "Saving…" state instead of looking inert.
125
- let saving = $state(false);
126
- // The same working state for the Publish button, which rides the edit form via formaction. The
127
- // submit handler reads the submitter to flip the right one, so Save never reads "Saving…" while
128
- // a publish is in flight.
129
- let publishing = $state(false);
130
- function onEditSubmit(e: SubmitEvent) {
131
- const formaction = (e.submitter as HTMLButtonElement | null)?.getAttribute('formaction');
132
- if (formaction === '?/publish') publishing = true;
133
- else saving = true;
134
- // Commit any pending personal-dictionary additions alongside the save. Fire-and-forget: the words
135
- // are already live in the Worker, so the in-flight commit never blocks the save navigation; an add
136
- // that does not land stays pending for the next save (declared before the navigation reads it).
137
- void commitPendingDictionary();
138
- }
139
- // Either in-flight submit disables both buttons, so a second click cannot fire a second POST
140
- // while the first navigation is still pending.
141
- const busy = $derived(saving || publishing);
142
- // True once a non-edit POST (discard, delete, rename) submits. Those forms navigate the
143
- // document without flipping busy, so without this the leave guard would fire mid-discard while
144
- // the page is still dirty, which is the primary discard scenario.
145
- let leaving = $state(false);
146
-
147
- // Dirty tracking. The body compares against the text the page loaded with (or the edited body a
148
- // blocked save returned, which seeded the editor); the uncontrolled sidebar fields flip a flag
149
- // on any input event, and the navigation a save triggers reloads the page, which resets both.
150
- const bodyDirty = $derived(body !== (form?.body ?? data.body));
151
- let fieldsDirty = $state(false);
152
- const dirty = $derived(bodyDirty || fieldsDirty);
153
- // What the header's save-state indicator says.
154
- const saveState = $derived(dirty ? 'Unsaved changes' : data.saved ? 'Saved' : '');
155
- function onFormInput(e: Event) {
156
- const target = e.target as Element | null;
157
- // Two kinds of input event bubble through the form without being frontmatter edits: the link
158
- // picker's search box (its dialog sits in the toolbar snippet) and the editing surface's
159
- // contenteditable. Skipping the surface keeps body edits owned by bodyDirty, so undoing back
160
- // to the committed text reads clean again.
161
- if (target?.closest('dialog, #cairn-pane-write')) return;
162
- fieldsDirty = true;
19
+ <script lang="ts">import { flushSync, untrack, getContext } from "svelte";
20
+ import { beforeNavigate } from "$app/navigation";
21
+ import { deserialize } from "$app/forms";
22
+ import { page } from "$app/state";
23
+ import BlocksIcon from "@lucide/svelte/icons/blocks";
24
+ import SquarePenIcon from "@lucide/svelte/icons/square-pen";
25
+ import LinkIcon from "@lucide/svelte/icons/link";
26
+ import FileSymlinkIcon from "@lucide/svelte/icons/file-symlink";
27
+ import PanelRightIcon from "@lucide/svelte/icons/panel-right";
28
+ import ImageIcon from "@lucide/svelte/icons/image";
29
+ import { useTopbar } from "./topbar-context.js";
30
+ import CsrfField from "./CsrfField.svelte";
31
+ import MarkdownEditor from "./MarkdownEditor.svelte";
32
+ import EditorToolbar from "./EditorToolbar.svelte";
33
+ import ComponentInsertDialog, { insertableDefs, hasSchema } from "./ComponentInsertDialog.svelte";
34
+ import LinkPicker from "./LinkPicker.svelte";
35
+ import WebLinkDialog from "./WebLinkDialog.svelte";
36
+ import MediaInsertPopover from "./MediaInsertPopover.svelte";
37
+ import MediaHeroField from "./MediaHeroField.svelte";
38
+ import MediaFigureControl from "./MediaFigureControl.svelte";
39
+ import DeleteDialog from "./DeleteDialog.svelte";
40
+ import RenameDialog from "./RenameDialog.svelte";
41
+ import MarkdownHelpDialog from "./MarkdownHelpDialog.svelte";
42
+ import ShortcutsDialog from "./ShortcutsDialog.svelte";
43
+ import TidyReview from "./TidyReview.svelte";
44
+ import SparklesIcon from "@lucide/svelte/icons/sparkles";
45
+ import { validateTidy, TIDY_REJECTION_MESSAGE } from "./tidy-validate.js";
46
+ import { cairnLinkCompletionSource } from "./link-completion.js";
47
+ import {
48
+ findMediaImagesNeedingAlt,
49
+ unwrapCairnLink,
50
+ unwrapFigure,
51
+ updateFigure,
52
+ wrapImageInFigure
53
+ } from "./markdown-format.js";
54
+ import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices } from "./preview-doc.js";
55
+ import { directiveLineKind, findInlineDirectives } from "./markdown-directives.js";
56
+ import { parseComponent, componentRoundTripSafety } from "../render/component-grammar.js";
57
+ import { manifestLinkResolver } from "../content/manifest.js";
58
+ import { manifestMediaResolver } from "../render/resolve-media.js";
59
+ import { mediaLibraryEntry } from "../media/library-entry.js";
60
+ import { CSRF_CONTEXT_KEY } from "./csrf-context.js";
61
+ let { data, registry, render, icons, form } = $props();
62
+ const TIDY_CLIENT_TIMEOUT_MS = 45e3;
63
+ const topbar = useTopbar();
64
+ $effect(() => {
65
+ if (!topbar) return;
66
+ topbar.desk = desk;
67
+ topbar.zen = zen;
68
+ return () => {
69
+ topbar.desk = null;
70
+ topbar.zen = false;
71
+ };
72
+ });
73
+ let body = $state(untrack(() => form?.body ?? data.body));
74
+ let saving = $state(false);
75
+ let publishing = $state(false);
76
+ function onEditSubmit(e) {
77
+ const formaction = e.submitter?.getAttribute("formaction");
78
+ if (formaction === "?/publish") publishing = true;
79
+ else saving = true;
80
+ void commitPendingDictionary();
81
+ }
82
+ const busy = $derived(saving || publishing);
83
+ let leaving = $state(false);
84
+ const bodyDirty = $derived(body !== (form?.body ?? data.body));
85
+ let fieldsDirty = $state(false);
86
+ const dirty = $derived(bodyDirty || fieldsDirty);
87
+ const saveState = $derived(dirty ? "Unsaved changes" : data.saved ? "Saved" : "");
88
+ function onFormInput(e) {
89
+ const target = e.target;
90
+ if (target?.closest("dialog, #cairn-pane-write")) return;
91
+ fieldsDirty = true;
92
+ }
93
+ function markFieldsDirty() {
94
+ fieldsDirty = true;
95
+ }
96
+ let editForm = $state(null);
97
+ let publishButton = $state(null);
98
+ function onFormInvalid(e) {
99
+ const target = e.target;
100
+ if (target?.closest("aside")) {
101
+ if (!detailsOpen) flushSync(() => detailsOpen = true);
102
+ return;
163
103
  }
164
- // Mark the details fields dirty without a form input event. The hero field writes its value to
165
- // hidden inputs, whose programmatic value changes do not fire the form's oninput, so it signals
166
- // dirty through this helper instead.
167
- function markFieldsDirty() {
168
- fieldsDirty = true;
104
+ if (mode === "write") return;
105
+ flushSync(() => mode = "write");
106
+ }
107
+ beforeNavigate((navigation) => {
108
+ if (navigation.willUnload) {
109
+ if (dirty && !busy && !leaving) navigation.cancel();
110
+ return;
169
111
  }
170
-
171
- // The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
172
- let editForm = $state<HTMLFormElement | null>(null);
173
- // The header's Publish submitter, for the Ctrl/Cmd+Shift+S shortcut: requesting submit through it
174
- // carries the ?/publish formaction and trips the busy flags down the existing submit path. It
175
- // exists only while data.pending, so the shortcut no-ops when there is nothing to publish.
176
- let publishButton = $state<HTMLButtonElement | null>(null);
177
-
178
- // A required field hidden from the browser's validation report cannot take it: an invisible
179
- // control is unfocusable, so the browser cancels the save silently with no message. Two surfaces
180
- // can hide a required field, so this capture-phase invalid listener reveals whichever holds the
181
- // invalid control before the report that follows fires. A field in the write pane needs Preview
182
- // flipped back to Write; a field in the details slide-over needs the panel opened. flushSync
183
- // forces the reveal inside the event, so the report lands on a now-visible control.
184
- function onFormInvalid(e: Event) {
185
- const target = e.target as Element | null;
186
- if (target?.closest('aside')) {
187
- if (!detailsOpen) flushSync(() => (detailsOpen = true));
112
+ if (dirty && !busy && !leaving && !confirm("You have unsaved changes. Leave anyway?"))
113
+ navigation.cancel();
114
+ });
115
+ $effect(() => {
116
+ const onBeforeUnload = (e) => {
117
+ if (dirty && !busy && !leaving) e.preventDefault();
118
+ };
119
+ const onWindowKeydown = (e) => {
120
+ if (e.key === "Escape" && (detailsOpen || zen)) {
121
+ const inDialog2 = !!e.target?.closest?.("dialog");
122
+ if (inDialog2) return;
123
+ e.preventDefault();
124
+ if (detailsOpen) closeDetails();
125
+ else setZen(false);
188
126
  return;
189
127
  }
190
- if (mode === 'write') return;
191
- flushSync(() => (mode = 'write'));
192
- }
193
-
194
- // The SvelteKit half of the leave guard. Registered at component init (beforeNavigate wraps
195
- // onMount, so it must run synchronously here) and auto-unregistered on destroy. A submit's own
196
- // navigation passes through because busy flips before it starts, and a non-edit POST's because
197
- // leaving does.
198
- beforeNavigate((navigation) => {
199
- // A full-page unload (refresh, tab close, external link): per SvelteKit semantics, cancel()
200
- // on a leave navigation is what asks the browser for its native dialog, so no confirm()
201
- // here or two prompts would stack. The beforeunload listener below is deliberate
202
- // belt-and-braces, not the dialog's source.
203
- if (navigation.willUnload) {
204
- if (dirty && !busy && !leaving) navigation.cancel();
128
+ if (!(e.ctrlKey || e.metaKey)) return;
129
+ const key = e.key.toLowerCase();
130
+ const inDialog = !!e.target?.closest?.("dialog");
131
+ if (!e.shiftKey && !e.altKey && e.key === "/") {
132
+ e.preventDefault();
133
+ if (inDialog) return;
134
+ shortcutsDialog?.open();
205
135
  return;
206
136
  }
207
- if (dirty && !busy && !leaving && !confirm('You have unsaved changes. Leave anyway?'))
208
- navigation.cancel();
209
- });
210
-
211
- // The browser half of the leave guard plus the page-wide save shortcut. The handlers read the
212
- // current dirty and busy values at event time, so the effect itself tracks nothing and runs once.
213
- $effect(() => {
214
- const onBeforeUnload = (e: BeforeUnloadEvent) => {
215
- if (dirty && !busy && !leaving) e.preventDefault();
216
- };
217
- // Guard-clause style on purpose: svelte 5.56.1 misprints `(a || b) && c` by dropping the
218
- // parentheses, and consumers compile this source with their own svelte.
219
- const onWindowKeydown = (e: KeyboardEvent) => {
220
- // Escape precedence, top to bottom: an open dialog claims Escape natively, so step aside
221
- // when one is up. Otherwise the details slide-over closes first (Task 8: it is a region, not
222
- // a dialog, so it has no native light-dismiss), and only when no panel is open does Escape
223
- // exit zen. So under zen with the panel open, the first Escape closes the panel and the
224
- // second exits zen, which keeps the two affordances independent.
225
- if (e.key === 'Escape' && (detailsOpen || zen)) {
226
- const inDialog = !!(e.target as Element | null)?.closest?.('dialog');
227
- if (inDialog) return;
228
- e.preventDefault();
229
- if (detailsOpen) closeDetails();
230
- else setZen(false);
231
- return;
232
- }
233
- if (!(e.ctrlKey || e.metaKey)) return;
234
- const key = e.key.toLowerCase();
235
- // The page-wide chords never act on a surface the author cannot see: a save, publish, mode
236
- // flip, or focus toggle from inside an open modal is suppressed the same way.
237
- const inDialog = !!(e.target as Element | null)?.closest?.('dialog');
238
- // Ctrl+/ opens the shortcuts sheet, the third discoverability surface. It reads off e.key so
239
- // it survives the shifted glyph differences across layouts, and it stays clear of dialogs the
240
- // same way the other chords do (the sheet is itself a dialog, so opening from inside one would
241
- // stack modals over a surface the author cannot see).
242
- if (!e.shiftKey && !e.altKey && e.key === '/') {
243
- e.preventDefault();
244
- if (inDialog) return;
245
- shortcutsDialog?.open();
246
- return;
247
- }
248
- if (e.shiftKey && key === 's') {
249
- // Publish rides the header's Publish submitter so the ?/publish formaction and the busy
250
- // flags follow the existing submit path; it exists only while pending, so this no-ops
251
- // otherwise.
252
- e.preventDefault();
253
- if (busy || inDialog || !data.pending) return;
254
- editForm?.requestSubmit(publishButton);
255
- return;
256
- }
257
- if (e.altKey && key === 'p') {
258
- e.preventDefault();
259
- if (inDialog) return;
260
- setMode(mode === 'write' ? 'preview' : 'write');
261
- return;
262
- }
263
- if (e.shiftKey && key === 'f') {
264
- e.preventDefault();
265
- if (inDialog) return;
266
- setFocusMode(!focusMode);
267
- return;
268
- }
269
- // Ctrl+Shift+. toggles zen (the bindings' zen key); the period reads off e.key. This sits
270
- // before the Ctrl+. panel block so the shifted chord is not mistaken for the panel toggle.
271
- if (e.shiftKey && !e.altKey && e.key === '.') {
272
- e.preventDefault();
273
- if (inDialog) return;
274
- setZen(!zen);
275
- return;
276
- }
277
- // Ctrl+. toggles the details slide-over (the bindings' panel key); the period reads off
278
- // e.key with no shift or alt.
279
- if (!e.shiftKey && !e.altKey && e.key === '.') {
280
- e.preventDefault();
281
- if (inDialog) return;
282
- toggleDetails();
283
- return;
284
- }
285
- if (e.shiftKey || e.altKey) return;
286
- if (key !== 's') return;
287
- // Always claim the shortcut so the browser's save-page dialog never opens over the admin.
137
+ if (e.shiftKey && key === "s") {
138
+ e.preventDefault();
139
+ if (busy || inDialog || !data.pending) return;
140
+ editForm?.requestSubmit(publishButton);
141
+ return;
142
+ }
143
+ if (e.altKey && key === "p") {
288
144
  e.preventDefault();
289
- // Gate the submit itself: an in-flight POST must not race a second one, a clean page has
290
- // nothing to save (a no-op save would still cut a pending branch), and a save from inside
291
- // an open modal would act on a surface the author cannot see.
292
- if (busy) return;
293
- if (!dirty && !data.isNew) return;
294
145
  if (inDialog) return;
295
- editForm?.requestSubmit();
296
- };
297
- window.addEventListener('beforeunload', onBeforeUnload);
298
- window.addEventListener('keydown', onWindowKeydown);
299
- return () => {
300
- window.removeEventListener('beforeunload', onBeforeUnload);
301
- window.removeEventListener('keydown', onWindowKeydown);
302
- };
303
- });
304
- // The discard confirm, on the DeleteDialog pattern: a native <dialog> holding the POST form.
305
- let discardDialog = $state<HTMLDialogElement | null>(null);
306
- // Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
307
- // landing tab.
308
- let mode = $state<'write' | 'preview'>('write');
309
- // Preview is read-only, so the insert controls the page renders into the toolbar disable with the
310
- // strip's own format buttons. Declared here (above the Edit-block derivations that read it) so it
311
- // is in scope before its first use.
312
- // Tidy mode disables the toolbar and makes the surface read-only while a review is open. The host
313
- // sets it when the review opens and clears it on apply or cancel. Declared here so the insert-disable
314
- // derivation below can read it.
315
- let tidyMode = $state(false);
316
- // The tidy request in-flight flag, so the Tidy control reads busy while a call runs.
317
- let tidyBusy = $state(false);
318
- // The insert controls disable in Preview (read-only) and while a tidy review is open (the author
319
- // cannot edit underneath a pending review, the same posture Preview takes).
320
- const insertDisabled = $derived(mode === 'preview' || tidyMode);
321
- let previewHtml = $state('');
322
- // True after a render call threw, so the preview pane can say so instead of going blank.
323
- let previewFailed = $state(false);
324
- // The preview frame's device width, a per-browser preference under its own key (the legacy
325
- // 'cairn-admin:preview' key from the removed split-pane preview stays untouched). Desktop is
326
- // the default; the storage read sits in an effect so it never runs during SSR, and it tracks
327
- // nothing reactive, so it runs once.
328
- const deviceStorageKey = 'cairn-editor-preview-device';
329
- let device = $state<PreviewDeviceId>('desktop');
330
- $effect(() => {
331
- const stored = localStorage.getItem(deviceStorageKey);
332
- if (previewDevices.some((d) => d.id === stored)) device = stored as PreviewDeviceId;
333
- });
334
- function setDevice(id: PreviewDeviceId) {
335
- device = id;
336
- localStorage.setItem(deviceStorageKey, id);
337
- }
338
- // The writing modes (focus, typewriter) and the surface posture, per-browser preferences on
339
- // the device pick's pattern: read in an effect so SSR never touches localStorage, written by
340
- // the card footer's toggles. The effect tracks nothing reactive, so it runs once.
341
- const focusStorageKey = 'cairn-editor-focus-mode';
342
- const typewriterStorageKey = 'cairn-editor-typewriter';
343
- const surfaceStorageKey = 'cairn-editor-surface';
344
- const zenStorageKey = 'cairn-editor-zen';
345
- // Spellcheck (the markdown-aware lint underlines) defaults ON, so a fresh editor checks spelling
346
- // without a choice. The toggle joins the editor-preference family on the same pattern: a localStorage
347
- // key read once in the effect below, written by the footer setter. Stored as 'false' only when the
348
- // author turns it off; any other value (including unset) reads as on.
349
- const spellcheckStorageKey = 'cairn-editor-spellcheck';
350
- let focusMode = $state(false);
351
- let typewriter = $state(false);
352
- let spellcheck = $state(true);
353
- // Zen: the manuscript alone on the recessed ground. The band, the document title, the toolbar
354
- // strip, and the footer go; the editing surface stays. It joins the editor-preference family on
355
- // the same pattern (a localStorage key, read once below, written by the setter), and composes
356
- // with focus mode and the postures rather than resetting them.
357
- let zen = $state(false);
358
- // The surface posture: prose (the writing instrument) by default; markup is the dense
359
- // working surface.
360
- let surface = $state<'prose' | 'markup'>('prose');
361
- $effect(() => {
362
- focusMode = localStorage.getItem(focusStorageKey) === 'true';
363
- typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
364
- zen = localStorage.getItem(zenStorageKey) === 'true';
365
- if (localStorage.getItem(surfaceStorageKey) === 'markup') surface = 'markup';
366
- // Spellcheck is on unless the author explicitly stored it off.
367
- spellcheck = localStorage.getItem(spellcheckStorageKey) !== 'false';
368
- });
369
- function setFocusMode(on: boolean) {
370
- focusMode = on;
371
- localStorage.setItem(focusStorageKey, String(on));
372
- }
373
- function setTypewriter(on: boolean) {
374
- typewriter = on;
375
- localStorage.setItem(typewriterStorageKey, String(on));
376
- }
377
- function setSpellcheck(on: boolean) {
378
- spellcheck = on;
379
- localStorage.setItem(spellcheckStorageKey, String(on));
380
- }
381
-
382
- // The personal-dictionary pending additions (spec 1.6), owned here and shared with MarkdownEditor's
383
- // lint source: an add-to-dictionary choice records the lowercased word here (and clears the underline
384
- // at once), and this host commits the set through the addDictionaryWord action at save time. An add
385
- // that fails to commit stays here for the session and re-attempts on the next save, so the word is
386
- // never silently dropped. A plain Set, not $state: the lint source mutates it, and nothing renders
387
- // from it, so reactivity is unneeded.
388
- const pendingAdditions = new Set<string>();
389
- // The CSRF token getter from the admin layout context, for the raw-body dictionary commit.
390
- const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
391
-
392
- /** Commit the pending personal-dictionary additions through the addDictionaryWord action, then drop
393
- * the words the server confirms from the pending set. Fire-and-forget at save time: the words are
394
- * already live in the Worker's in-memory set, so a slow or failed commit never blocks the save. A
395
- * failure leaves the words pending for the next save (never dropped). The transport mirrors the media
396
- * raw-body actions: a text/plain POST, the CSRF token in X-Cairn-CSRF, a JSON `{ words }` body. */
397
- async function commitPendingDictionary(): Promise<void> {
398
- if (pendingAdditions.size === 0) return;
399
- const words = [...pendingAdditions];
400
- try {
401
- const res = await fetch(`/admin/${data.conceptId}/${data.id}?/addDictionaryWord`, {
402
- method: 'POST',
403
- redirect: 'manual',
404
- headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
405
- body: JSON.stringify({ words }),
406
- });
407
- // The guard's expired-session 303 under redirect: 'manual' surfaces as an opaque, status-0
408
- // response with no body: leave the words pending and bail.
409
- if (res.type === 'opaqueredirect' || res.status === 0) return;
410
- // deserialize turns the devalue-encoded form-action result back into an ActionResult; a 500 HTML
411
- // error page is not devalue-encoded, so it throws into the catch below. Only a success carries
412
- // the merged word list; a fail (csrf, 400, 409) leaves the words pending for the next save.
413
- const result = deserialize(await res.text()) as
414
- | { type: 'success'; data?: { words?: unknown } }
415
- | { type: 'failure' | 'error' | 'redirect' };
416
- if (result.type !== 'success') return;
417
- const merged = result.data?.words;
418
- if (!Array.isArray(merged)) return;
419
- // Reconcile: drop every now-committed word (matched lowercased, the form the action stored) from
420
- // the pending set so it is not re-sent. A word the server did not confirm stays pending.
421
- const committed = new Set(merged.filter((w): w is string => typeof w === 'string').map((w) => w.toLowerCase()));
422
- for (const w of words) if (committed.has(w.toLowerCase())) pendingAdditions.delete(w);
423
- } catch {
424
- // A network failure or an unparseable server response leaves the pending set intact for the next
425
- // save; the words stay live in the Worker for the session, so the author sees no regression.
146
+ setMode(mode === "write" ? "preview" : "write");
147
+ return;
426
148
  }
427
- }
428
- function setSurface(posture: 'prose' | 'markup') {
429
- surface = posture;
430
- localStorage.setItem(surfaceStorageKey, posture);
431
- }
432
- function setZen(on: boolean) {
433
- // Entering zen hides the band, the document title, the toolbar strip, and the footer. Focus on
434
- // any of those (a band action like Publish, a strip button, the title input, a footer toggle)
435
- // would strand on a detached node when its host leaves the DOM, so move focus into the editing
436
- // surface first. The surface (.cm-editor) and the exit chip are all that survive, so any focus
437
- // outside the surface is about to hide. Reading activeElement before the DOM updates is what
438
- // tells a hiding control from the surviving one.
439
- const surface = editorCard?.querySelector('.cm-editor');
440
- const focusHides = on && !surface?.contains(document.activeElement);
441
- zen = on;
442
- localStorage.setItem(zenStorageKey, String(on));
443
- if (focusHides) {
444
- // flushSync applies the zen layout (the strip and footer leave the DOM) before we reach for
445
- // the surface, so the focus call lands on the now-sole interactive region.
446
- flushSync();
447
- (editorCard?.querySelector('.cm-content') as HTMLElement | null)?.focus();
149
+ if (e.shiftKey && key === "f") {
150
+ e.preventDefault();
151
+ if (inDialog) return;
152
+ setFocusMode(!focusMode);
153
+ return;
154
+ }
155
+ if (e.shiftKey && !e.altKey && e.key === ".") {
156
+ e.preventDefault();
157
+ if (inDialog) return;
158
+ setZen(!zen);
159
+ return;
160
+ }
161
+ if (!e.shiftKey && !e.altKey && e.key === ".") {
162
+ e.preventDefault();
163
+ if (inDialog) return;
164
+ toggleDetails();
165
+ return;
448
166
  }
167
+ if (e.shiftKey || e.altKey) return;
168
+ if (key !== "s") return;
169
+ e.preventDefault();
170
+ if (busy) return;
171
+ if (!dirty && !data.isNew) return;
172
+ if (inDialog) return;
173
+ editForm?.requestSubmit();
174
+ };
175
+ window.addEventListener("beforeunload", onBeforeUnload);
176
+ window.addEventListener("keydown", onWindowKeydown);
177
+ return () => {
178
+ window.removeEventListener("beforeunload", onBeforeUnload);
179
+ window.removeEventListener("keydown", onWindowKeydown);
180
+ };
181
+ });
182
+ let discardDialog = $state(null);
183
+ let mode = $state("write");
184
+ let tidyMode = $state(false);
185
+ let tidyBusy = $state(false);
186
+ const insertDisabled = $derived(mode === "preview" || tidyMode);
187
+ let previewHtml = $state("");
188
+ let previewFailed = $state(false);
189
+ const deviceStorageKey = "cairn-editor-preview-device";
190
+ let device = $state("desktop");
191
+ $effect(() => {
192
+ const stored = localStorage.getItem(deviceStorageKey);
193
+ if (previewDevices.some((d) => d.id === stored)) device = stored;
194
+ });
195
+ function setDevice(id) {
196
+ device = id;
197
+ localStorage.setItem(deviceStorageKey, id);
198
+ }
199
+ const focusStorageKey = "cairn-editor-focus-mode";
200
+ const typewriterStorageKey = "cairn-editor-typewriter";
201
+ const surfaceStorageKey = "cairn-editor-surface";
202
+ const zenStorageKey = "cairn-editor-zen";
203
+ const spellcheckStorageKey = "cairn-editor-spellcheck";
204
+ let focusMode = $state(false);
205
+ let typewriter = $state(false);
206
+ let spellcheck = $state(true);
207
+ let zen = $state(false);
208
+ let surface = $state("prose");
209
+ $effect(() => {
210
+ focusMode = localStorage.getItem(focusStorageKey) === "true";
211
+ typewriter = localStorage.getItem(typewriterStorageKey) === "true";
212
+ zen = localStorage.getItem(zenStorageKey) === "true";
213
+ if (localStorage.getItem(surfaceStorageKey) === "markup") surface = "markup";
214
+ spellcheck = localStorage.getItem(spellcheckStorageKey) !== "false";
215
+ });
216
+ function setFocusMode(on) {
217
+ focusMode = on;
218
+ localStorage.setItem(focusStorageKey, String(on));
219
+ }
220
+ function setTypewriter(on) {
221
+ typewriter = on;
222
+ localStorage.setItem(typewriterStorageKey, String(on));
223
+ }
224
+ function setSpellcheck(on) {
225
+ spellcheck = on;
226
+ localStorage.setItem(spellcheckStorageKey, String(on));
227
+ }
228
+ const pendingAdditions = /* @__PURE__ */ new Set();
229
+ const csrf = getContext(CSRF_CONTEXT_KEY);
230
+ async function commitPendingDictionary() {
231
+ if (pendingAdditions.size === 0) return;
232
+ const words = [...pendingAdditions];
233
+ try {
234
+ const res = await fetch(`/admin/${data.conceptId}/${data.id}?/addDictionaryWord`, {
235
+ method: "POST",
236
+ redirect: "manual",
237
+ headers: { "Content-Type": "text/plain", "X-Cairn-CSRF": csrf?.() ?? "" },
238
+ body: JSON.stringify({ words })
239
+ });
240
+ if (res.type === "opaqueredirect" || res.status === 0) return;
241
+ const result = deserialize(await res.text());
242
+ if (result.type !== "success") return;
243
+ const merged = result.data?.words;
244
+ if (!Array.isArray(merged)) return;
245
+ const committed = new Set(merged.filter((w) => typeof w === "string").map((w) => w.toLowerCase()));
246
+ for (const w of words) if (committed.has(w.toLowerCase())) pendingAdditions.delete(w);
247
+ } catch {
449
248
  }
450
- // The footer controls dress as what they are (the spec's rule). Each helper returns a verbatim
451
- // Tailwind class string: the admin CSS build's @source scan reads this file as raw text, so the
452
- // utilities must appear whole, never assembled from fragments.
453
- //
454
- // A segment of the bordered posture control (the mockup's .seg). The shared group border carries
455
- // the pick-one semantics, so a segment stays borderless; the active one tints and bolds. The
456
- // admin's scoped button reset (cairn-admin.css) already strips the UA border and fill.
457
- function segButtonClass(pressed: boolean): string {
458
- return `inline-flex items-center gap-1 px-2.5 py-1 text-xs font-normal ${pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
249
+ }
250
+ function setSurface(posture) {
251
+ surface = posture;
252
+ localStorage.setItem(surfaceStorageKey, posture);
253
+ }
254
+ function setZen(on) {
255
+ const surface2 = editorCard?.querySelector(".cm-editor");
256
+ const focusHides = on && !surface2?.contains(document.activeElement);
257
+ zen = on;
258
+ localStorage.setItem(zenStorageKey, String(on));
259
+ if (focusHides) {
260
+ flushSync();
261
+ editorCard?.querySelector(".cm-content")?.focus();
459
262
  }
460
- // A standalone writing-mode toggle (the mockup's .ftr-toggle): rounded, transparent until hover,
461
- // check-and-tint when pressed.
462
- function ftrToggleClass(pressed: boolean): string {
463
- return `ftr-toggle inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-normal hover:bg-base-content/[0.06] ${pressed ? 'bg-primary/10 text-primary font-medium' : 'text-[var(--color-muted)]'}`;
263
+ }
264
+ function segButtonClass(pressed) {
265
+ return `inline-flex items-center gap-1 px-2.5 py-1 text-xs font-normal ${pressed ? "bg-primary/10 text-primary font-medium" : "text-[var(--color-muted)]"}`;
266
+ }
267
+ function ftrToggleClass(pressed) {
268
+ return `ftr-toggle inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-normal hover:bg-base-content/[0.06] ${pressed ? "bg-primary/10 text-primary font-medium" : "text-[var(--color-muted)]"}`;
269
+ }
270
+ const activeDevice = $derived(previewDevice(device));
271
+ const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
272
+ let insert = $state.raw(() => {
273
+ });
274
+ let replaceRange = $state.raw(() => {
275
+ });
276
+ let selectRange = $state.raw(() => {
277
+ });
278
+ let insertLink = $state.raw(() => {
279
+ });
280
+ let getSelection = $state.raw(() => "");
281
+ let getSelectionRange = $state.raw(() => null);
282
+ let format = $state.raw(() => {
283
+ });
284
+ let tidyApi = $state.raw(null);
285
+ let undoEditor = $state.raw(() => {
286
+ });
287
+ let tidyReview = $state.raw(null);
288
+ let tidyMessage = $state(null);
289
+ let tidyNoop = $state(false);
290
+ let tidyApplied = $state(false);
291
+ let tidyController = null;
292
+ let tidyWorkingDialog = $state(null);
293
+ let tidyNoopDialog = $state(null);
294
+ let tidyMessageDialog = $state(null);
295
+ $effect(() => {
296
+ if (tidyBusy) tidyWorkingDialog?.showModal();
297
+ });
298
+ $effect(() => {
299
+ if (tidyNoop) tidyNoopDialog?.showModal();
300
+ });
301
+ $effect(() => {
302
+ if (tidyMessage) tidyMessageDialog?.showModal();
303
+ });
304
+ const tidyEnabled = $derived(data.tidy?.enabled ?? false);
305
+ async function runTidy() {
306
+ if (!tidyEnabled || tidyBusy || tidyMode) return;
307
+ tidyMessage = null;
308
+ tidyNoop = false;
309
+ tidyApplied = false;
310
+ const selected = getSelection();
311
+ const range = getSelectionRange();
312
+ const useSelection = selected.length > 0;
313
+ let offset = 0;
314
+ if (range) {
315
+ offset = range.from;
316
+ } else if (useSelection) {
317
+ offset = Math.max(body.indexOf(selected), 0);
464
318
  }
465
- const activeDevice = $derived(previewDevice(device));
466
- // The iframe document around the rendered html: the site's stylesheets from the adapter's
467
- // preview knob, or a styleless document (behind the hint below) when the site sets none.
468
- const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
469
- let insert = $state.raw<(text: string) => void>(() => {});
470
- // The editor's range-replace seam, registered by MarkdownEditor on mount; the dialog's Update
471
- // routes through it to overwrite an edited block's source span. A no-op until then.
472
- let replaceRange = $state.raw<(from: number, to: number, text: string) => void>(() => {});
473
- // The editor's select-range seam, registered by MarkdownEditor on mount; the needs-alt notice's
474
- // jump control routes through it to land the author on an image that lacks alt. A no-op until then.
475
- let selectRange = $state.raw<(from: number, to: number) => void>(() => {});
476
- let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
477
- // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
478
- // reads it for the Text field's default.
479
- let getSelection = $state.raw<() => string>(() => '');
480
- // The editor's selection range, registered by MarkdownEditor on mount; tidy reads it for the exact
481
- // selected span's offset so a selection tidy never maps onto an identical passage earlier in the
482
- // document. Returns null when the selection is empty (a bare caret), which reads as document scope.
483
- let getSelectionRange = $state.raw<() => { from: number; to: number } | null>(() => null);
484
- // The editor's selection transform, registered by MarkdownEditor on mount; a no-op until then.
485
- let format = $state.raw<(kind: FormatKind) => void>(() => {});
486
-
487
- // The tidy apply seam, registered by MarkdownEditor on mount; the review surface drives the in-buffer
488
- // decorations and the batched apply through it. Null until the editor mounts.
489
- let tidyApi = $state.raw<import('./editor-tidy.js').TidyApi | null>(null);
490
- // The editor's undo, registered on mount; the Undo-tidy chip calls it. A no-op until then.
491
- let undoEditor = $state.raw<() => void>(() => {});
492
- // The open review's data: the validated change set, the captured original it was diffed against, the
493
- // scope, and the model. Null when no review is open. The diff positions index `tidyOriginal`, which
494
- // for a selection tidy is the FULL document (the changes are offset back before they reach here).
495
- let tidyReview = $state.raw<{ changes: Change[]; original: string; model: string } | null>(null);
496
- // The error message a refused or failed tidy surfaces. The working state is cancelable through the
497
- // AbortController; a validation rejection or an action failure lands here.
498
- let tidyMessage = $state<string | null>(null);
499
- // The no-op confirmation: a clean result (tidy found nothing to fix) shows "Nothing to fix" and never
500
- // opens an empty review. Cleared on the next tidy run.
501
- let tidyNoop = $state(false);
502
- // The session-level "Undo tidy" affordance: surfaced right after Apply, dismissed on the next edit.
503
- let tidyApplied = $state(false);
504
- // The in-flight controller, for Cancel and the bounded client timeout.
505
- let tidyController: AbortController | null = null;
506
-
507
- // The three tidy status dialogs (working, no-op, message). Each is promoted to the top layer with
508
- // showModal() the way TidyReview does, so the focus trap, Escape, and inert background come from the
509
- // platform. The $effect below opens each when its flag flips and closes it when the flag clears; the
510
- // {#if} mounts the element, so the ref is set before the effect reads it.
511
- let tidyWorkingDialog = $state<HTMLDialogElement | null>(null);
512
- let tidyNoopDialog = $state<HTMLDialogElement | null>(null);
513
- let tidyMessageDialog = $state<HTMLDialogElement | null>(null);
514
- $effect(() => {
515
- if (tidyBusy) tidyWorkingDialog?.showModal();
516
- });
517
- $effect(() => {
518
- if (tidyNoop) tidyNoopDialog?.showModal();
519
- });
520
- $effect(() => {
521
- if (tidyMessage) tidyMessageDialog?.showModal();
522
- });
523
-
524
- // True when tidy is enabled for the site (the developer-tier master switch). Gates the Tidy control.
525
- // The optional chain mirrors the component's tolerance of a partial data load: a degraded load that
526
- // omits the tidy block simply reads disabled rather than throwing.
527
- const tidyEnabled = $derived(data.tidy?.enabled ?? false);
528
-
529
- /** Run tidy (spec 2.1, Task 11) over the whole document or the current selection. The action receives
530
- * only the selected text plus a scope flag; the diff is computed against that text and the changes'
531
- * ranges are offset back into the full document before they reach the apply seam. On success the
532
- * result is validated as a proofread (Task 13); a rejection shows the honest message and writes
533
- * nothing; a clean result shows "Nothing to fix"; otherwise the review opens. */
534
- async function runTidy() {
535
- if (!tidyEnabled || tidyBusy || tidyMode) return;
536
- tidyMessage = null;
537
- tidyNoop = false;
538
- tidyApplied = false;
539
- // Scope: a non-empty selection tidies that range; otherwise the whole body. The offset is where the
540
- // selected text begins in the full document, so the diff positions map back. The range seam carries
541
- // the exact selection offsets, so a passage that repeats earlier in the body still maps the
542
- // corrections onto the actually-selected occurrence. Fall back to the first textual match only when
543
- // no range is available (offset 0 keeps document-scope tidy unchanged).
544
- const selected = getSelection();
545
- const range = getSelectionRange();
546
- const useSelection = selected.length > 0;
547
- let offset = 0;
548
- if (range) {
549
- offset = range.from;
550
- } else if (useSelection) {
551
- offset = Math.max(body.indexOf(selected), 0);
319
+ const text = useSelection ? selected : body;
320
+ tidyBusy = true;
321
+ tidyController = new AbortController();
322
+ const timer = setTimeout(() => tidyController?.abort(), TIDY_CLIENT_TIMEOUT_MS);
323
+ try {
324
+ const res = await fetch(`/admin/${data.conceptId}/${data.id}?/tidy`, {
325
+ method: "POST",
326
+ redirect: "manual",
327
+ headers: { "Content-Type": "text/plain", "X-Cairn-CSRF": csrf?.() ?? "" },
328
+ body: JSON.stringify({ text, scope: useSelection ? "selection" : "document" }),
329
+ signal: tidyController.signal
330
+ });
331
+ if (res.type === "opaqueredirect" || res.status === 0) {
332
+ tidyMessage = "Your session expired. Sign in again to tidy.";
333
+ return;
552
334
  }
553
- const text = useSelection ? selected : body;
554
-
555
- tidyBusy = true;
556
- tidyController = new AbortController();
557
- // The bounded client timeout: a slow call becomes a cancel/retry rather than hanging the review.
558
- const timer = setTimeout(() => tidyController?.abort(), TIDY_CLIENT_TIMEOUT_MS);
559
- try {
560
- const res = await fetch(`/admin/${data.conceptId}/${data.id}?/tidy`, {
561
- method: 'POST',
562
- redirect: 'manual',
563
- headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
564
- body: JSON.stringify({ text, scope: useSelection ? 'selection' : 'document' }),
565
- signal: tidyController.signal,
566
- });
567
- if (res.type === 'opaqueredirect' || res.status === 0) {
568
- tidyMessage = 'Your session expired. Sign in again to tidy.';
569
- return;
570
- }
571
- const result = deserialize(await res.text()) as
572
- | { type: 'success'; data?: { corrected?: unknown; model?: unknown } }
573
- | { type: 'failure'; data?: { error?: unknown } }
574
- | { type: 'error' | 'redirect' };
575
- if (result.type !== 'success') {
576
- tidyMessage =
577
- result.type === 'failure' && typeof result.data?.error === 'string' && result.data.error !== 'csrf'
578
- ? result.data.error
579
- : 'Tidy could not finish. Try again.';
580
- return;
581
- }
582
- const corrected = typeof result.data?.corrected === 'string' ? result.data.corrected : '';
583
- const model = typeof result.data?.model === 'string' ? result.data.model : data.tidy.model;
584
- if (corrected.length === 0 || corrected === text) {
585
- // A clean result: tidy found nothing to fix. Never open an empty review.
586
- tidyNoop = true;
587
- return;
588
- }
589
- // Validate the result as a proofread (Task 13). A rejection writes nothing and shows the message.
590
- const validation = validateTidy(text, corrected);
591
- if (!validation.ok) {
592
- tidyMessage = TIDY_REJECTION_MESSAGE;
593
- return;
594
- }
595
- if (validation.changes.length === 0) {
596
- tidyNoop = true;
597
- return;
598
- }
599
- // Offset the changes back into the full document (a selection tidy diffs the selected text). The
600
- // captured original handed to the review is the full body, so every line label and context row is
601
- // computed against the real document.
602
- const changes: Change[] = validation.changes.map((c) => ({
603
- ...c,
604
- from: c.from + offset,
605
- to: c.to + offset,
606
- }));
607
- tidyReview = { changes, original: body, model };
608
- tidyMode = true;
609
- tidyApi?.enter(changes);
610
- } catch {
611
- // An abort (Cancel or the client timeout) or a network/parse failure both map to the same
612
- // retryable message; the buffer is untouched.
613
- tidyMessage = tidyController?.signal.aborted ? null : 'Tidy could not finish. Try again.';
614
- } finally {
615
- clearTimeout(timer);
616
- tidyController = null;
617
- tidyBusy = false;
335
+ const result = deserialize(await res.text());
336
+ if (result.type !== "success") {
337
+ tidyMessage = result.type === "failure" && typeof result.data?.error === "string" && result.data.error !== "csrf" ? result.data.error : "Tidy could not finish. Try again.";
338
+ return;
618
339
  }
619
- }
620
-
621
- /** Cancel an in-flight tidy: abort the request and clear the working state. The buffer is untouched. */
622
- function cancelTidy() {
623
- tidyController?.abort();
340
+ const corrected = typeof result.data?.corrected === "string" ? result.data.corrected : "";
341
+ const model = typeof result.data?.model === "string" ? result.data.model : data.tidy.model;
342
+ if (corrected.length === 0 || corrected === text) {
343
+ tidyNoop = true;
344
+ return;
345
+ }
346
+ const validation = validateTidy(text, corrected);
347
+ if (!validation.ok) {
348
+ tidyMessage = TIDY_REJECTION_MESSAGE;
349
+ return;
350
+ }
351
+ if (validation.changes.length === 0) {
352
+ tidyNoop = true;
353
+ return;
354
+ }
355
+ const changes = validation.changes.map((c) => ({
356
+ ...c,
357
+ from: c.from + offset,
358
+ to: c.to + offset
359
+ }));
360
+ tidyReview = { changes, original: body, model };
361
+ tidyMode = true;
362
+ tidyApi?.enter(changes);
363
+ } catch {
364
+ tidyMessage = tidyController?.signal.aborted ? null : "Tidy could not finish. Try again.";
365
+ } finally {
366
+ clearTimeout(timer);
367
+ tidyController = null;
624
368
  tidyBusy = false;
625
- tidyMessage = null;
626
- }
627
-
628
- /** Close the review: clear tidy mode and the review data. On apply the "Undo tidy" affordance shows
629
- * until the next edit; on cancel nothing changed. */
630
- function closeTidyReview(applied: boolean) {
631
- tidyMode = false;
632
- tidyReview = null;
633
- tidyApplied = applied;
634
- // Record the body the apply produced, so the next edit (a different body) dismisses the Undo chip.
635
- tidyAppliedBody = applied ? body : null;
636
369
  }
637
- // The body snapshot right after Apply; the Undo-tidy chip dismisses once the body diverges from it.
638
- let tidyAppliedBody = $state<string | null>(null);
639
- $effect(() => {
640
- const current = body;
641
- if (tidyApplied && tidyAppliedBody !== null && current !== tidyAppliedBody) {
642
- tidyApplied = false;
643
- tidyAppliedBody = null;
644
- }
645
- });
646
- // Undo the whole applied tidy in one move (ordinary editor Undo of the one batched transaction). The
647
- // chip names it so the author knows the whole tidy is one move back.
648
- function undoTidy() {
649
- undoEditor();
370
+ }
371
+ function cancelTidy() {
372
+ tidyController?.abort();
373
+ tidyBusy = false;
374
+ tidyMessage = null;
375
+ }
376
+ function closeTidyReview(applied) {
377
+ tidyMode = false;
378
+ tidyReview = null;
379
+ tidyApplied = applied;
380
+ tidyAppliedBody = applied ? body : null;
381
+ }
382
+ let tidyAppliedBody = $state(null);
383
+ $effect(() => {
384
+ const current = body;
385
+ if (tidyApplied && tidyAppliedBody !== null && current !== tidyAppliedBody) {
650
386
  tidyApplied = false;
651
387
  tidyAppliedBody = null;
652
388
  }
653
-
654
- // The media insert seams, registered by MarkdownEditor on mount, mirroring the range holders
655
- // above. The popover drives the optimistic upload loop through them: the caret anchor, the focus
656
- // restore, the placeholder api, and the direct-insert path for a picked image. The placeholder
657
- // api type is referenced inline (import('...').Type), never a static `import type ... from`, so
658
- // no static edge to the dynamically-imported editor-placeholder module sits in this component
659
- // (the editor-boundary test bars that edge by a textual `from` scan).
660
- let caretCoords = $state.raw<() => { left: number; right: number; top: number; bottom: number } | null>(
661
- () => null,
662
- );
663
- let focusEditor = $state.raw<() => void>(() => {});
664
- let placeholders = $state.raw<import('./editor-placeholder.js').ImagePlaceholderApi | null>(null);
665
- let insertImageFn = $state.raw<(alt: string, ref: string) => void>(() => {});
666
-
667
- // A no-op placeholder api so the editor object handed to the popover is never null before the
668
- // editor registers its real one on mount.
669
- const noopPlaceholders: import('./editor-placeholder.js').ImagePlaceholderApi = {
670
- begin: () => 0,
671
- progress: () => {},
672
- resolveTo: () => {},
673
- cancel: () => {},
674
- };
675
-
676
- // The editor object the popover drives, delegating through the holders so the latest registered
677
- // function is always used (the holders start as no-ops and are replaced on mount).
678
- const editorApi = $derived({
679
- caretCoords: () => caretCoords(),
680
- focusEditor: () => focusEditor(),
681
- placeholders: placeholders ?? noopPlaceholders,
682
- insertImage: (alt: string, ref: string) => insertImageFn(alt, ref),
683
- });
684
-
685
- // The headless media insert popover, opened from the toolbar control, paste, or drop.
686
- let mediaPopover = $state<MediaInsertPopover | null>(null);
687
-
688
- // The rendered hero fields' refs (for the needs-alt notice's "Add alt text" action, which focuses
689
- // the field's own alt input) and their reported needs-alt signals, keyed by field name. A hero is
690
- // a frontmatter value with no body offset, so its needs-alt signal comes from the field, not the
691
- // body scanner (findMediaImagesNeedingAlt), and its remediation focuses the alt input, never a
692
- // source range (selectRange). The records are keyed by field name; `data.fields` is static for the
693
- // page's lifetime, so a key never goes stale (no per-key cleanup on unmount is needed).
694
- let heroFieldRefs = $state<Record<string, MediaHeroField>>({});
695
- let heroNeedsAlt = $state<Record<string, boolean>>({});
696
-
697
- // The server-owned records from each successful upload this session. They ride the save form as
698
- // the hidden `media` field, so the save action merges them into media.json.
699
- let uploadedRecords = $state<MediaEntry[]>([]);
700
- // A headless dialog instance, typed structurally over its exported open() (the linkPicker idiom).
701
- type DialogHandle = { open: () => void };
702
- // The toolbar's insert dialogs. Each holds its own <form>, so they mount outside the edit form
703
- // (a form nested in a form is invalid HTML the parser repairs by dropping the outer tag, which
704
- // breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
705
- let webLinkDialog = $state<DialogHandle | null>(null);
706
- let linkPicker = $state<DialogHandle | null>(null);
707
- // The insert dialog binds the full instance, not the bare DialogHandle: the Edit-block control
708
- // drives editComponent(def, values, range) on it, beyond the shared open().
709
- let insertDialog = $state<ComponentInsertDialog | null>(null);
710
- // The lifecycle dialogs, opened from the header's overflow menu.
711
- let deleteDialog = $state<DialogHandle | null>(null);
712
- let renameDialog = $state<DialogHandle | null>(null);
713
- // The Markdown cheat sheet, opened from the editor card's footer.
714
- let helpDialog = $state<DialogHandle | null>(null);
715
- // The keyboard shortcuts sheet, opened from anywhere on the desk by Ctrl+/.
716
- let shortcutsDialog = $state<DialogHandle | null>(null);
717
-
718
- // Whether the registry offers anything insertable, the same condition the insert dialog lists
719
- // by, so the toolbar trigger and the dialog appear and disappear together.
720
- const hasComponents = $derived(insertableDefs(registry).length > 0);
721
-
722
- // The directive container at the editor caret, reported by MarkdownEditor whenever it changes
723
- // (null outside any container). The Edit-block control resolves it against the registry and the
724
- // round-trip safety gate below; its identity is the key the async gate guards against a stale
725
- // result. The reporter's name+markdown+from+to shape; declared locally because MarkdownEditor's
726
- // matching interface is not exported.
727
- type CaretComponent = { name: string | null; markdown: string; from: number; to: number };
728
- let caretComponent = $state<CaretComponent | null>(null);
729
-
730
- // The media image at the editor caret, reported by MarkdownEditor whenever it changes (null off any
731
- // media image). The Figure control reads it to wrap a bare image or edit an existing figure; it
732
- // writes source through the replaceRange seam. The figure dialog is mounted headless below.
733
- let mediaAtCaret = $state<FigureAtImage | null>(null);
734
- // The figure control's host <dialog>, opened by the toolbar control. Mounted outside the edit form
735
- // (a form nested in a form is invalid HTML), the Edit-block dialog pattern.
736
- let figureDialog = $state<HTMLDialogElement | null>(null);
737
- // Whether the Figure control is available: a media image sits at the caret and Preview is not
738
- // showing (the insert controls disable together with the Write surface). The control is always
739
- // rendered (it never mounts on caret move); only its enabled state changes.
740
- const figureAvailable = $derived(mediaAtCaret != null && !insertDisabled);
741
- const figureLabel = $derived(
742
- figureAvailable
743
- ? mediaAtCaret?.figure
744
- ? 'Edit the figure at the cursor'
745
- : 'Wrap the image at the cursor in a figure'
746
- : 'Place the cursor on an image to add a figure',
747
- );
748
- // Whether the image at the caret is decorative (empty or whitespace-only alt). The token came from
749
- // a parsed image node, so the alt is the source between `![` and the closing `]` before `](`. An
750
- // empty alt is the needs-alt signal; the figure control surfaces it and the decorative-plus-caption
751
- // warning. Derived from the reported token so it tracks the caret.
752
- const figureDecorative = $derived.by(() => {
753
- if (!mediaAtCaret) return false;
754
- const token = body.slice(mediaAtCaret.imageFrom, mediaAtCaret.imageTo);
755
- const match = /^!\[([\s\S]*?)\]\(/.exec(token);
756
- return (match?.[1] ?? '').trim() === '';
757
- });
758
- // A def is actionable for guided edit when it has a schema (the same notion the insert catalog
759
- // lists by): a template-only def has no form to re-open into. Reuses the dialog's exported
760
- // hasSchema so the two surfaces can never drift on what counts as editable.
761
- function editableDef(name: string | null): ComponentDef | undefined {
762
- if (!name) return undefined;
763
- const def = registry?.get(name);
764
- if (!def) return undefined;
765
- return hasSchema(def) ? def : undefined;
389
+ });
390
+ function undoTidy() {
391
+ undoEditor();
392
+ tidyApplied = false;
393
+ tidyAppliedBody = null;
394
+ }
395
+ let caretCoords = $state.raw(
396
+ () => null
397
+ );
398
+ let focusEditor = $state.raw(() => {
399
+ });
400
+ let placeholders = $state.raw(null);
401
+ let insertImageFn = $state.raw(() => {
402
+ });
403
+ const noopPlaceholders = {
404
+ begin: () => 0,
405
+ progress: () => {
406
+ },
407
+ resolveTo: () => {
408
+ },
409
+ cancel: () => {
410
+ }
411
+ };
412
+ const editorApi = $derived({
413
+ caretCoords: () => caretCoords(),
414
+ focusEditor: () => focusEditor(),
415
+ placeholders: placeholders ?? noopPlaceholders,
416
+ insertImage: (alt, ref) => insertImageFn(alt, ref)
417
+ });
418
+ let mediaPopover = $state(null);
419
+ let heroFieldRefs = $state({});
420
+ let heroNeedsAlt = $state({});
421
+ let uploadedRecords = $state([]);
422
+ let webLinkDialog = $state(null);
423
+ let linkPicker = $state(null);
424
+ let insertDialog = $state(null);
425
+ let deleteDialog = $state(null);
426
+ let renameDialog = $state(null);
427
+ let helpDialog = $state(null);
428
+ let shortcutsDialog = $state(null);
429
+ const hasComponents = $derived(insertableDefs(registry).length > 0);
430
+ let caretComponent = $state(null);
431
+ let mediaAtCaret = $state(null);
432
+ let figureDialog = $state(null);
433
+ const figureAvailable = $derived(mediaAtCaret != null && !insertDisabled);
434
+ const figureLabel = $derived(
435
+ figureAvailable ? mediaAtCaret?.figure ? "Edit the figure at the cursor" : "Wrap the image at the cursor in a figure" : "Place the cursor on an image to add a figure"
436
+ );
437
+ const figureDecorative = $derived.by(() => {
438
+ if (!mediaAtCaret) return false;
439
+ const token = body.slice(mediaAtCaret.imageFrom, mediaAtCaret.imageTo);
440
+ const match = /^!\[([\s\S]*?)\]\(/.exec(token);
441
+ return (match?.[1] ?? "").trim() === "";
442
+ });
443
+ function editableDef(name) {
444
+ if (!name) return void 0;
445
+ const def = registry?.get(name);
446
+ if (!def) return void 0;
447
+ return hasSchema(def) ? def : void 0;
448
+ }
449
+ let editable = $state(null);
450
+ let editReason = $state("none");
451
+ $effect(() => {
452
+ const current = caretComponent;
453
+ const def = editableDef(current?.name ?? null);
454
+ if (!current || !def) {
455
+ editable = null;
456
+ editReason = "none";
457
+ return;
766
458
  }
767
- // The resolved editability: the def, the source range, and the validated markdown when the caret
768
- // sits on a known, schema-bearing component whose round-trip safety check passed for the CURRENT
769
- // caret; null otherwise. The def, range, and markdown are captured from one caretComponent
770
- // snapshot, so editBlock() never mixes a newer markdown with an older range. The Edit-block
771
- // control enables only when this is set.
772
- let editable = $state<{ def: ComponentDef; range: { from: number; to: number }; markdown: string } | null>(null);
773
- // Why edit is unavailable, distinguishing "not on a component" from "on an unsafe one" so the
774
- // disabled tooltip is honest. 'none' covers both no-caret-component and an unknown/template-only
775
- // one (no guided form either way); 'unsafe' is a known component the safety gate refused.
776
- let editReason = $state<'none' | 'unsafe'>('none');
777
- // Resolve editability when the caret-component changes, async-safe. componentRoundTripSafety is
778
- // async, so a slow check could resolve after a newer caret move; guard latest-wins on the
779
- // caretComponent identity (the EditPage preview-debounce pattern), only applying a result when
780
- // the caret has not moved since the check started. A block whose check did not pass for the
781
- // current caret never enables edit.
782
- $effect(() => {
783
- const current = caretComponent;
784
- const def = editableDef(current?.name ?? null);
785
- if (!current || !def) {
459
+ let stale = false;
460
+ void componentRoundTripSafety(current.markdown, def).then((result) => {
461
+ if (stale || caretComponent !== current) return;
462
+ if (result.safe) {
463
+ editable = { def, range: { from: current.from, to: current.to }, markdown: current.markdown };
464
+ editReason = "none";
465
+ } else {
786
466
  editable = null;
787
- editReason = 'none';
788
- return;
467
+ editReason = "unsafe";
789
468
  }
790
- let stale = false;
791
- void componentRoundTripSafety(current.markdown, def)
792
- .then((result) => {
793
- if (stale || caretComponent !== current) return;
794
- if (result.safe) {
795
- editable = { def, range: { from: current.from, to: current.to }, markdown: current.markdown };
796
- editReason = 'none';
797
- } else {
798
- editable = null;
799
- editReason = 'unsafe';
800
- }
801
- })
802
- .catch(() => {
803
- // A parse throw during the safety check must never leave a stale block enabled. Guarded by
804
- // the same latest-wins identity, fall back to the safe default of no editable block.
805
- if (stale || caretComponent !== current) return;
806
- editable = null;
807
- editReason = 'none';
808
- });
809
- return () => {
810
- stale = true;
811
- };
812
- });
813
- // The Edit-block control's accessible label and tooltip: a plain reason in each state. Enabled
814
- // names the action; the two disabled reasons are honest about why.
815
- const editBlockLabel = $derived(
816
- editable
817
- ? 'Edit the component at the cursor'
818
- : editReason === 'unsafe'
819
- ? "This block can't be edited in the form. Edit it as markdown."
820
- : 'Place the cursor in a component to edit it',
821
- );
822
- // Whether the Edit-block control is unavailable: either Preview hides the Write surface, or the
823
- // caret is not on a safe, schema-bearing component. The control stays focusable and announced in
824
- // this state (aria-disabled, not the native disabled attribute), so its reason reaches assistive
825
- // technology; the dead click is made inert in editBlock().
826
- const editBlockUnavailable = $derived(insertDisabled || !editable);
827
- // Activate edit: parse the block into form values, then open the dialog in edit mode over the
828
- // stored source range. Guarded by editable AND the preview-mode disable, so the control is inert
829
- // unless the gate passed and the editor is on the Write tab. The def, range, and markdown all come
830
- // from the one editable snapshot, so a newer caret markdown is never paired with an older range.
831
- async function editBlock() {
832
- if (insertDisabled || !editable) return;
833
- const values = await parseComponent(editable.markdown, editable.def);
834
- insertDialog?.editComponent(editable.def, values, editable.range);
835
- }
836
-
837
- // The figure dialog's pre-fill, snapshotted when the control opens so the form never mixes a newer
838
- // caret with the values it opened on. Captured from mediaAtCaret at open time: edit mode with the
839
- // figure's caption/role when a figure wraps the image, else wrap mode with empty caption and the
840
- // measure default. decorative rides the snapshot too. Null while the dialog is closed.
841
- let figurePrefill = $state<{
842
- mode: 'wrap' | 'edit';
843
- caption: string;
844
- role: FigureRole | null;
845
- decorative: boolean;
846
- image: { from: number; to: number };
847
- figureRange: { from: number; to: number } | null;
848
- } | null>(null);
849
-
850
- // Open the figure control over the media image at the caret. Inert unless a media image sits there
851
- // and the Write surface is up, the same gate the toolbar control shows. The snapshot is the source
852
- // of truth for the apply handlers, so a caret move while the dialog is open never re-targets it.
853
- function openFigure() {
854
- if (!figureAvailable || !mediaAtCaret) return;
855
- const at = mediaAtCaret;
856
- figurePrefill = {
857
- mode: at.figure ? 'edit' : 'wrap',
858
- caption: at.figure?.caption ?? '',
859
- role: at.figure?.role ?? null,
860
- decorative: figureDecorative,
861
- image: { from: at.imageFrom, to: at.imageTo },
862
- figureRange: at.figure ? { from: at.figure.from, to: at.figure.to } : null,
863
- };
864
- figureDialog?.showModal();
865
- }
866
-
867
- // Apply the control's choice through the replaceRange seam, then close. Wrap a bare image or update
868
- // an existing figure, off the snapshot the dialog opened on. The pure transform owns the source
869
- // shape and keeps the media token byte-intact; the preview stays read-only.
870
- function applyFigure(choice: { caption: string; role: FigureRole | null }) {
871
- const pre = figurePrefill;
872
- if (!pre) return;
873
- const result =
874
- pre.mode === 'edit' && pre.figureRange
875
- ? updateFigure(body, pre.figureRange, choice.caption, choice.role)
876
- : wrapImageInFigure(body, pre.image.from, pre.image.to, choice.caption, choice.role);
877
- writeFigureResult(result);
878
- }
879
-
880
- // Unwrap the figure back to its bare image, then close. Edit mode only (the snapshot carries the
881
- // figure range). The bare image token is restored verbatim by the pure transform.
882
- function unwrapFigureAction() {
883
- const pre = figurePrefill;
884
- if (!pre || !pre.figureRange) return;
885
- writeFigureResult(unwrapFigure(body, pre.figureRange));
886
- }
887
-
888
- // Write a figure transform's result back to the editor: overwrite the whole doc through the
889
- // replaceRange seam, then place the selection the transform chose (the seam alone drops the caret
890
- // at the end). replaceRange dispatches the doc change and focuses the surface; selectRange then
891
- // dispatches a selection-only transaction, which CodeMirror's history does not record as its own
892
- // undoable event, so one undo reverts the whole figure write. Close the dialog last.
893
- function writeFigureResult(result: { doc: string; from: number; to: number }) {
894
- replaceRange(0, body.length, result.doc);
895
- selectRange(result.from, result.to);
896
- figureDialog?.close();
897
- }
898
-
899
- // The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
900
- // when it has never been published); otherwise the live site matches and it reads Published.
901
- const status = $derived.by(() => {
902
- if (!data.pending) return 'Published';
903
- return data.published ? 'Edited' : 'New';
904
- });
905
- const statusBadge = $derived.by(() => {
906
- if (status === 'Edited') return 'badge-warning';
907
- if (status === 'New') return 'badge-info';
908
- return 'badge-ghost';
469
+ }).catch(() => {
470
+ if (stale || caretComponent !== current) return;
471
+ editable = null;
472
+ editReason = "none";
909
473
  });
910
-
911
- // The band overflow menu's popover element and its open state, mirrored from the toggle
912
- // event into aria-expanded on the trigger.
913
- let actionsMenu = $state<HTMLUListElement | null>(null);
914
- let actionsOpen = $state(false);
915
-
916
- // The details slide-over. The aside below carries the frontmatter groups; it stays physically
917
- // inside the edit form (so the uncontrolled fields submit) but presents as a fixed panel under
918
- // the band, hidden when closed so it leaves the a11y tree and the tab order while its
919
- // display:none fields still post. Focus moves into the panel on open and returns to the trigger
920
- // on close, the region-with-focus-management pattern (the a11y reviewer adjudicates region vs
921
- // dialog at the pass gate).
922
- let detailsOpen = $state(false);
923
- let detailsTrigger = $state<HTMLButtonElement | null>(null);
924
- let detailsClose = $state<HTMLButtonElement | null>(null);
925
- function openDetails() {
926
- // flushSync removes the panel's `hidden` attribute synchronously; a hidden element cannot
927
- // take focus, so the close button must be visible before we move focus to it.
928
- flushSync(() => (detailsOpen = true));
929
- detailsClose?.focus();
474
+ return () => {
475
+ stale = true;
476
+ };
477
+ });
478
+ const editBlockLabel = $derived(
479
+ editable ? "Edit the component at the cursor" : editReason === "unsafe" ? "This block can't be edited in the form. Edit it as markdown." : "Place the cursor in a component to edit it"
480
+ );
481
+ const editBlockUnavailable = $derived(insertDisabled || !editable);
482
+ async function editBlock() {
483
+ if (insertDisabled || !editable) return;
484
+ const values = await parseComponent(editable.markdown, editable.def);
485
+ insertDialog?.editComponent(editable.def, values, editable.range);
486
+ }
487
+ let figurePrefill = $state(null);
488
+ function openFigure() {
489
+ if (!figureAvailable || !mediaAtCaret) return;
490
+ const at = mediaAtCaret;
491
+ figurePrefill = {
492
+ mode: at.figure ? "edit" : "wrap",
493
+ caption: at.figure?.caption ?? "",
494
+ role: at.figure?.role ?? null,
495
+ decorative: figureDecorative,
496
+ image: { from: at.imageFrom, to: at.imageTo },
497
+ figureRange: at.figure ? { from: at.figure.from, to: at.figure.to } : null
498
+ };
499
+ figureDialog?.showModal();
500
+ }
501
+ function applyFigure(choice) {
502
+ const pre = figurePrefill;
503
+ if (!pre) return;
504
+ const result = pre.mode === "edit" && pre.figureRange ? updateFigure(body, pre.figureRange, choice.caption, choice.role) : wrapImageInFigure(body, pre.image.from, pre.image.to, choice.caption, choice.role);
505
+ writeFigureResult(result);
506
+ }
507
+ function unwrapFigureAction() {
508
+ const pre = figurePrefill;
509
+ if (!pre || !pre.figureRange) return;
510
+ writeFigureResult(unwrapFigure(body, pre.figureRange));
511
+ }
512
+ function writeFigureResult(result) {
513
+ replaceRange(0, body.length, result.doc);
514
+ selectRange(result.from, result.to);
515
+ figureDialog?.close();
516
+ }
517
+ const status = $derived.by(() => {
518
+ if (!data.pending) return "Published";
519
+ return data.published ? "Edited" : "New";
520
+ });
521
+ const statusBadge = $derived.by(() => {
522
+ if (status === "Edited") return "badge-warning";
523
+ if (status === "New") return "badge-info";
524
+ return "badge-ghost";
525
+ });
526
+ let actionsMenu = $state(null);
527
+ let actionsOpen = $state(false);
528
+ let detailsOpen = $state(false);
529
+ let detailsTrigger = $state(null);
530
+ let detailsClose = $state(null);
531
+ function openDetails() {
532
+ flushSync(() => detailsOpen = true);
533
+ detailsClose?.focus();
534
+ }
535
+ function closeDetails() {
536
+ detailsOpen = false;
537
+ detailsTrigger?.focus();
538
+ }
539
+ function toggleDetails() {
540
+ if (detailsOpen) closeDetails();
541
+ else openDetails();
542
+ }
543
+ function pickAction(action) {
544
+ action();
545
+ if (actionsMenu?.matches(":popover-open")) actionsMenu.hidePopover();
546
+ }
547
+ const brokenLinks = $derived(form?.brokenLinks ?? []);
548
+ let removedLinks = $state([]);
549
+ const visibleBrokenLinks = $derived(brokenLinks.filter((h) => !removedLinks.includes(h)));
550
+ function removeBrokenLink(href) {
551
+ const next = unwrapCairnLink(body, href);
552
+ if (next !== body) {
553
+ body = next;
554
+ removedLinks = [...removedLinks, href];
930
555
  }
931
- function closeDetails() {
556
+ }
557
+ const needsAlt = $derived(findMediaImagesNeedingAlt(body));
558
+ const imageFields = $derived(
559
+ data.fields.filter((f) => f.type === "image").map((f) => ({ name: f.name, label: f.label }))
560
+ );
561
+ const heroRows = $derived(imageFields.filter((f) => heroNeedsAlt[f.name]));
562
+ const needsAltCount = $derived(needsAlt.length + heroRows.length);
563
+ const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
564
+ const formError = $derived(
565
+ form?.error && !form.brokenLinks?.length && !form.inboundLinks?.length ? form.error : ""
566
+ );
567
+ const entryKey = $derived(data.conceptId + "/" + data.id);
568
+ let seededKey = untrack(() => entryKey);
569
+ $effect.pre(() => {
570
+ const key = entryKey;
571
+ if (key === seededKey) return;
572
+ seededKey = key;
573
+ untrack(() => {
574
+ body = form?.body ?? data.body;
575
+ saving = false;
576
+ publishing = false;
577
+ leaving = false;
578
+ fieldsDirty = false;
579
+ mode = "write";
932
580
  detailsOpen = false;
933
- detailsTrigger?.focus();
934
- }
935
- function toggleDetails() {
936
- if (detailsOpen) closeDetails();
937
- else openDetails();
581
+ previewHtml = "";
582
+ previewFailed = false;
583
+ removedLinks = [];
584
+ });
585
+ });
586
+ const draftWarning = $derived.by(() => {
587
+ const drafts = page.url.searchParams.get("drafts");
588
+ return drafts ? drafts.split(",").filter(Boolean).join(", ") : "";
589
+ });
590
+ const flash = $derived.by(() => {
591
+ if (data.saved && !draftWarning)
592
+ return "Saved. Your site keeps showing the published version until you publish.";
593
+ if (data.publishedFlash) return "Published. The live site is rebuilding.";
594
+ if (data.discardedFlash) return "Changes discarded.";
595
+ if (data.renamed) return `The URL is now ${data.slug}.`;
596
+ return "";
597
+ });
598
+ const politeMessage = $derived(
599
+ draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash
600
+ );
601
+ const assertiveMessage = $derived.by(() => {
602
+ if (data.error) return data.error;
603
+ if (formError) return formError;
604
+ if (deleteRefusedLinks.length) {
605
+ const count = deleteRefusedLinks.length;
606
+ return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? "page links" : "pages link"} to it.`;
938
607
  }
939
-
940
- // An overflow-menu pick runs its action, then dismisses the popover menu. Opening a modal
941
- // dialog already closes an auto popover, so the explicit hide fires only when the menu is
942
- // still up.
943
- function pickAction(action: () => void) {
944
- action();
945
- if (actionsMenu?.matches(':popover-open')) actionsMenu.hidePopover();
608
+ if (visibleBrokenLinks.length) {
609
+ const count = visibleBrokenLinks.length;
610
+ return `This page links to ${count} missing ${count === 1 ? "page" : "pages"}.`;
946
611
  }
947
-
948
- // The save guard's broken links, from the blocked action result. The fix unwraps a link in the
949
- // local body, which the bound editor reconciles, so the author re-saves clean.
950
- const brokenLinks = $derived(form?.brokenLinks ?? []);
951
- // Track the hrefs the author has already fixed this session. The banner reads the immutable action
952
- // result, so without this a fixed row would linger and "Remove link" would read as a no-op.
953
- let removedLinks = $state<string[]>([]);
954
- const visibleBrokenLinks = $derived(brokenLinks.filter((h) => !removedLinks.includes(h)));
955
- function removeBrokenLink(href: string) {
956
- // Hide the row only when the unwrap changed the body. A genuine no-op keeps the row honest.
957
- const next = unwrapCairnLink(body, href);
958
- if (next !== body) {
959
- body = next;
960
- removedLinks = [...removedLinks, href];
961
- }
962
- }
963
-
964
- // The media images in the live body that carry no alt text, recomputed as the author types. Alt is
965
- // accessibility debt, never a render or publish failure, so this drives a non-blocking warning the
966
- // author can act on or leave; the count drops and the notice clears as each alt is filled.
967
- const needsAlt = $derived(findMediaImagesNeedingAlt(body));
968
-
969
- // The declared image (hero) fields, for labelling the needs-alt notice's frontmatter rows.
970
- const imageFields = $derived(
971
- data.fields.filter((f) => f.type === 'image').map((f) => ({ name: f.name, label: f.label })),
972
- );
973
- // The frontmatter-hero needs-alt rows: each image field whose hero reports a needs-alt signal. The
974
- // row's action focuses the field's alt input (the body scanner and its source-range jump cannot
975
- // reach a frontmatter value). The headline count sums these with the body scanner's hits.
976
- const heroRows = $derived(imageFields.filter((f) => heroNeedsAlt[f.name]));
977
- const needsAltCount = $derived(needsAlt.length + heroRows.length);
978
-
979
- // The delete guard's inbound linkers, from a refused delete (fail 409). Empty when the delete was
980
- // not refused. When set, a delete was blocked by a link that appeared since the page loaded.
981
- const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
982
-
983
- // The shared failure summary, rendered only when no richer banner claims the failure: the save
984
- // and delete guards get their own banners from brokenLinks and inboundLinks below, so this
985
- // surfaces the rest (a rename refusal, today).
986
- const formError = $derived(
987
- form?.error && !form.brokenLinks?.length && !form.inboundLinks?.length ? form.error : '',
988
- );
989
-
990
- // The entry this surface is editing. SvelteKit reuses the page component across a same-route
991
- // navigation (the delete-refused and broken-link banners link entry to entry), so the per-entry
992
- // state seeded at init would survive the hop and show entry A's body over entry B's data with
993
- // the dirty indicator armed. When the identity changes, re-seed the state here; the {#key}
994
- // block around the template remounts the DOM to match (CodeMirror with its undo history, the
995
- // uncontrolled sidebar fields, any open dialog). The leave guard still protects the hop:
996
- // beforeNavigate runs before the navigation completes, so it reads the old dirty value.
997
- const entryKey = $derived(data.conceptId + '/' + data.id);
998
- let seededKey = untrack(() => entryKey);
999
- $effect.pre(() => {
1000
- const key = entryKey;
1001
- if (key === seededKey) return;
1002
- seededKey = key;
1003
- untrack(() => {
1004
- body = form?.body ?? data.body;
1005
- saving = false;
1006
- publishing = false;
1007
- leaving = false;
1008
- fieldsDirty = false;
1009
- mode = 'write';
1010
- detailsOpen = false;
1011
- previewHtml = '';
1012
- previewFailed = false;
1013
- removedLinks = [];
1014
- });
1015
- });
1016
-
1017
- // After a save that links to a draft target, the redirect carries ?drafts=<tokens>. page.url
1018
- // is reactive kit state, so a client-side navigation that swaps the search string re-derives
1019
- // this, and the read is SSR-safe.
1020
- const draftWarning = $derived.by(() => {
1021
- const drafts = page.url.searchParams.get('drafts');
1022
- return drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
1023
- });
1024
-
1025
- // The one transient feedback strip under the sticky header. The redirect flags are mutually
1026
- // exclusive in practice; the chain picks one so a surprise overlap still renders a single strip.
1027
- // A saved flash with a draft warning yields to the warning alert below, the prior behavior.
1028
- const flash = $derived.by(() => {
1029
- if (data.saved && !draftWarning)
1030
- return 'Saved. Your site keeps showing the published version until you publish.';
1031
- if (data.publishedFlash) return 'Published. The live site is rebuilding.';
1032
- if (data.discardedFlash) return 'Changes discarded.';
1033
- if (data.renamed) return `The URL is now ${data.slug}.`;
1034
- return '';
1035
- });
1036
-
1037
- // One persistent live region announces the current message, since a {#if}-gated role element
1038
- // inserted fresh is announced inconsistently. A polite region carries the success and draft
1039
- // notices (the flash, plus the draft notice the strip yields to); an assertive region carries
1040
- // the errors. The visible banners below keep their styling but drop their roles, so a message
1041
- // is announced once.
1042
- const politeMessage = $derived(
1043
- draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash,
1044
- );
1045
- const assertiveMessage = $derived.by(() => {
1046
- if (data.error) return data.error;
1047
- if (formError) return formError;
1048
- if (deleteRefusedLinks.length) {
1049
- const count = deleteRefusedLinks.length;
1050
- return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? 'page links' : 'pages link'} to it.`;
1051
- }
1052
- if (visibleBrokenLinks.length) {
1053
- const count = visibleBrokenLinks.length;
1054
- return `This page links to ${count} missing ${count === 1 ? 'page' : 'pages'}.`;
1055
- }
1056
- return '';
1057
- });
1058
-
1059
- // One line of body text reduced to its prose: inline directives drop wholesale, then the
1060
- // markdown marker characters become spaces. Spacing rather than deleting keeps "[text](url)"
1061
- // as two words instead of mashing the link text into its destination, so a link counts its
1062
- // text plus its URL and the count never undercounts prose.
1063
- function proseOnly(line: string): string {
1064
- let out = '';
1065
- let cursor = 0;
1066
- for (const { from, to } of findInlineDirectives(line)) {
1067
- out += line.slice(cursor, from);
1068
- cursor = to;
1069
- }
1070
- out += line.slice(cursor);
1071
- return out.replace(/[*_~`[\]()#]/g, ' ');
612
+ return "";
613
+ });
614
+ function proseOnly(line) {
615
+ let out = "";
616
+ let cursor = 0;
617
+ for (const { from, to } of findInlineDirectives(line)) {
618
+ out += line.slice(cursor, from);
619
+ cursor = to;
1072
620
  }
1073
-
1074
- // The editor footer's word count, over the local body so it tracks every keystroke. Directive
1075
- // machinery lines and table rows are dropped first and the inline syntax stripped, so the
1076
- // count reads as the author's prose.
1077
- const countedBody = $derived(
1078
- body
1079
- .split('\n')
1080
- .filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line))
1081
- .map(proseOnly)
1082
- .join('\n'),
1083
- );
1084
- const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
1085
- const wordLabel = $derived(wordCount === 1 ? '1 word' : `${wordCount} words`);
1086
-
1087
- // The manifest-backed resolver turns a cairn: link into its live permalink in the preview, and
1088
- // returns undefined for a missing target so the render step marks it cairn-broken-link.
1089
- const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
1090
-
1091
- // The media analog: it turns a media: reference into its /media delivery path in the preview, and
1092
- // returns undefined for a missing target so the render step marks it cairn-broken-media. The
1093
- // committed mediaTargets projection is merged with this session's uploaded records (the same
1094
- // override the picker's library does), so a just-uploaded image renders its thumbnail in the live
1095
- // preview before the next save commits it, rather than reading as a broken reference.
1096
- const resolveMediaTargets = $derived({
1097
- ...data.mediaTargets,
1098
- ...Object.fromEntries(
1099
- uploadedRecords.map((r) => [r.hash, { slug: r.slug, ext: r.ext, contentType: r.contentType }]),
1100
- ),
1101
- });
1102
- const resolveMedia = $derived(manifestMediaResolver(resolveMediaTargets));
1103
-
1104
- // The picker's library, the committed projection merged with this session's uploaded records,
1105
- // keyed by content hash. An uploaded record overrides a committed entry on a hash match (the same
1106
- // hash is the same bytes, so the override is harmless). This is what the editor decorates with, so
1107
- // a just-uploaded image carries its source chip before the next save commits it.
1108
- const mediaLibrary = $derived({
1109
- ...data.mediaLibrary,
1110
- ...Object.fromEntries(uploadedRecords.map((r) => [r.hash, mediaLibraryEntry(r)])),
1111
- });
1112
-
1113
- // The [[ autocomplete source over the same link targets, handed to the editor's generic seam.
1114
- const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
1115
-
1116
- function setMode(m: 'write' | 'preview') {
1117
- mode = m;
621
+ out += line.slice(cursor);
622
+ return out.replace(/[*_~`[\]()#]/g, " ");
623
+ }
624
+ const countedBody = $derived(
625
+ body.split("\n").filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line)).map(proseOnly).join("\n")
626
+ );
627
+ const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
628
+ const wordLabel = $derived(wordCount === 1 ? "1 word" : `${wordCount} words`);
629
+ const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
630
+ const resolveMediaTargets = $derived({
631
+ ...data.mediaTargets,
632
+ ...Object.fromEntries(
633
+ uploadedRecords.map((r) => [r.hash, { slug: r.slug, ext: r.ext, contentType: r.contentType }])
634
+ )
635
+ });
636
+ const resolveMedia = $derived(manifestMediaResolver(resolveMediaTargets));
637
+ const mediaLibrary = $derived({
638
+ ...data.mediaLibrary,
639
+ ...Object.fromEntries(uploadedRecords.map((r) => [r.hash, mediaLibraryEntry(r)]))
640
+ });
641
+ const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
642
+ function setMode(m) {
643
+ mode = m;
644
+ }
645
+ let editorCard = $state(null);
646
+ $effect(() => {
647
+ const card = editorCard;
648
+ if (!card) return;
649
+ card.addEventListener("keydown", onEditorKeydown);
650
+ return () => card.removeEventListener("keydown", onEditorKeydown);
651
+ });
652
+ function onEditorKeydown(e) {
653
+ if (!(e.ctrlKey || e.metaKey)) return;
654
+ const key = e.key.toLowerCase();
655
+ const fmt = formatForKeydown(e);
656
+ if (fmt) {
657
+ e.preventDefault();
658
+ format(fmt);
659
+ } else if (key === "b") {
660
+ e.preventDefault();
661
+ format("bold");
662
+ } else if (key === "i") {
663
+ e.preventDefault();
664
+ format("italic");
665
+ } else if (key === "k") {
666
+ e.preventDefault();
667
+ webLinkDialog?.open();
1118
668
  }
1119
-
1120
- // The editor card's keyboard shortcuts. Bound to the card so they fire wherever focus sits in the
1121
- // strip or the surface, without claiming the keys page-wide. The listener attaches
1122
- // programmatically: it is event delegation, not an interaction affordance, which Svelte's a11y
1123
- // rule cannot tell apart on a declarative handler.
1124
- let editorCard = $state<HTMLDivElement | null>(null);
1125
- $effect(() => {
1126
- const card = editorCard;
1127
- if (!card) return;
1128
- card.addEventListener('keydown', onEditorKeydown);
1129
- return () => card.removeEventListener('keydown', onEditorKeydown);
1130
- });
1131
- function onEditorKeydown(e: KeyboardEvent) {
1132
- if (!(e.ctrlKey || e.metaKey)) return;
1133
- const key = e.key.toLowerCase();
1134
- // The shifted-digit list trio (Ctrl+Shift+9/8/7) arrives as '('/'*'/'&' for e.key on US
1135
- // layouts, so the digit identity comes from e.code; the heading pair rides Ctrl+Alt+2/3.
1136
- const fmt = formatForKeydown(e);
1137
- if (fmt) {
1138
- e.preventDefault();
1139
- format(fmt);
1140
- } else if (key === 'b') {
1141
- e.preventDefault();
1142
- format('bold');
1143
- } else if (key === 'i') {
1144
- e.preventDefault();
1145
- format('italic');
1146
- } else if (key === 'k') {
1147
- e.preventDefault();
1148
- webLinkDialog?.open();
1149
- }
669
+ }
670
+ function formatForKeydown(e) {
671
+ if (e.altKey) {
672
+ if (e.key === "2") return "h2";
673
+ if (e.key === "3") return "h3";
674
+ return null;
1150
675
  }
1151
- // Maps the format-key chords to their FormatKind. Inline code is the plain Ctrl+E; quote and the
1152
- // two lists are Ctrl+Shift with the digit read from e.code (the shifted key glyph is layout
1153
- // dependent); the headings are the Ctrl+Alt+2/3 Google Docs idiom.
1154
- function formatForKeydown(e: KeyboardEvent): FormatKind | null {
1155
- if (e.altKey) {
1156
- if (e.key === '2') return 'h2';
1157
- if (e.key === '3') return 'h3';
1158
- return null;
1159
- }
1160
- if (e.shiftKey) {
1161
- if (e.code === 'Digit9') return 'quote';
1162
- if (e.code === 'Digit8') return 'ul';
1163
- if (e.code === 'Digit7') return 'ol';
1164
- return null;
1165
- }
1166
- if (e.key.toLowerCase() === 'e') return 'code';
676
+ if (e.shiftKey) {
677
+ if (e.code === "Digit9") return "quote";
678
+ if (e.code === "Digit8") return "ul";
679
+ if (e.code === "Digit7") return "ol";
1167
680
  return null;
1168
681
  }
1169
-
1170
- // Render the design-accurate preview as the body changes, debounced. The site's render is the
1171
- // floored engine pipeline, so its output is already sanitized; the preview mirrors the page.
1172
- // previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
1173
- // async render call resolves after a newer one has started, the stale result is discarded.
1174
- let previewRun = 0;
1175
- $effect(() => {
1176
- if (mode !== 'preview' || !render) return;
1177
- const md = body;
1178
- const resolve = resolveLink; // tracked read in the effect body
1179
- const resolveMediaRef = resolveMedia; // tracked read in the effect body
1180
- const run = ++previewRun;
1181
- const handle = setTimeout(async () => {
1182
- try {
1183
- const html = await render(md, { resolve, resolveMedia: resolveMediaRef });
1184
- if (run === previewRun) {
1185
- previewHtml = html;
1186
- previewFailed = false;
1187
- }
1188
- } catch {
1189
- if (run === previewRun) {
1190
- previewHtml = '';
1191
- previewFailed = true;
1192
- }
682
+ if (e.key.toLowerCase() === "e") return "code";
683
+ return null;
684
+ }
685
+ let previewRun = 0;
686
+ $effect(() => {
687
+ if (mode !== "preview" || !render) return;
688
+ const md = body;
689
+ const resolve = resolveLink;
690
+ const resolveMediaRef = resolveMedia;
691
+ const run = ++previewRun;
692
+ const handle = setTimeout(async () => {
693
+ try {
694
+ const html = await render(md, { resolve, resolveMedia: resolveMediaRef });
695
+ if (run === previewRun) {
696
+ previewHtml = html;
697
+ previewFailed = false;
1193
698
  }
1194
- }, 150);
1195
- return () => {
1196
- clearTimeout(handle);
1197
- // Every re-run and the final teardown invalidate the in-flight render. The entry-key reset
1198
- // above cannot reach this counter, so without the bump a slow render for entry A could
1199
- // resolve after a same-route hop and write A's html into entry B's pane.
1200
- previewRun++;
1201
- };
1202
- });
1203
-
1204
- // Coerce a frontmatter value to a string for text/date/textarea inputs.
1205
- function str(v: unknown): string {
1206
- return v == null ? '' : String(v);
1207
- }
1208
-
1209
- // The eyebrow legend each sidebar group opens with, one class string for all three.
1210
- const eyebrowClass =
1211
- 'mb-2 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
1212
-
1213
- // The sidebar's grouping. The title field hoists above the editor card as the document title,
1214
- // and a boolean named draft becomes the Visibility group's Hidden toggle (both production
1215
- // adapters use that name); everything else is a Details field.
1216
- const titleField = $derived(data.fields.find((f) => f.name === 'title'));
1217
- const draftField = $derived(data.fields.find((f) => f.type === 'boolean' && f.name === 'draft'));
1218
- const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
699
+ } catch {
700
+ if (run === previewRun) {
701
+ previewHtml = "";
702
+ previewFailed = true;
703
+ }
704
+ }
705
+ }, 150);
706
+ return () => {
707
+ clearTimeout(handle);
708
+ previewRun++;
709
+ };
710
+ });
711
+ function str(v) {
712
+ return v == null ? "" : String(v);
713
+ }
714
+ const eyebrowClass = "mb-2 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]";
715
+ const titleField = $derived(data.fields.find((f) => f.name === "title"));
716
+ const draftField = $derived(data.fields.find((f) => f.type === "boolean" && f.name === "draft"));
717
+ const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
1219
718
  </script>
1220
719
 
1221
720
  <!-- The desk controls live in the one header band: AdminLayout renders this snippet through the