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