@glw907/cairn-cms 0.38.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +7 -6
  3. package/dist/components/AdminLayout.svelte +53 -0
  4. package/dist/components/ComponentInsertDialog.svelte +27 -13
  5. package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
  6. package/dist/components/ConceptList.svelte +22 -3
  7. package/dist/components/DeleteDialog.svelte +18 -7
  8. package/dist/components/DeleteDialog.svelte.d.ts +11 -1
  9. package/dist/components/EditPage.svelte +604 -75
  10. package/dist/components/EditPage.svelte.d.ts +8 -1
  11. package/dist/components/EditorToolbar.svelte +206 -29
  12. package/dist/components/EditorToolbar.svelte.d.ts +12 -4
  13. package/dist/components/LinkPicker.svelte +14 -6
  14. package/dist/components/LinkPicker.svelte.d.ts +9 -2
  15. package/dist/components/MarkdownEditor.svelte +80 -34
  16. package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
  17. package/dist/components/MarkdownHelpDialog.svelte +58 -0
  18. package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
  19. package/dist/components/RenameDialog.svelte +13 -4
  20. package/dist/components/RenameDialog.svelte.d.ts +9 -1
  21. package/dist/components/WebLinkDialog.svelte +89 -0
  22. package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
  23. package/dist/components/cairn-admin.css +353 -4
  24. package/dist/components/editor-highlight.d.ts +9 -0
  25. package/dist/components/editor-highlight.js +62 -0
  26. package/dist/components/link-completion.js +10 -3
  27. package/dist/components/markdown-directives.d.ts +7 -0
  28. package/dist/components/markdown-directives.js +22 -0
  29. package/dist/components/markdown-format.d.ts +1 -1
  30. package/dist/components/markdown-format.js +91 -12
  31. package/dist/content/pending.d.ts +9 -0
  32. package/dist/content/pending.js +24 -0
  33. package/dist/diagnostics/conditions.d.ts +8 -1
  34. package/dist/diagnostics/conditions.js +68 -1
  35. package/dist/doctor/bin.d.ts +2 -0
  36. package/dist/doctor/bin.js +44 -0
  37. package/dist/doctor/check-send.d.ts +3 -0
  38. package/dist/doctor/check-send.js +43 -0
  39. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  40. package/dist/doctor/checks-cloudflare.js +200 -0
  41. package/dist/doctor/checks-github.d.ts +2 -0
  42. package/dist/doctor/checks-github.js +57 -0
  43. package/dist/doctor/checks-local.d.ts +5 -0
  44. package/dist/doctor/checks-local.js +112 -0
  45. package/dist/doctor/cloudflare-api.d.ts +7 -0
  46. package/dist/doctor/cloudflare-api.js +24 -0
  47. package/dist/doctor/index.d.ts +23 -0
  48. package/dist/doctor/index.js +68 -0
  49. package/dist/doctor/report.d.ts +5 -0
  50. package/dist/doctor/report.js +21 -0
  51. package/dist/doctor/run.d.ts +8 -0
  52. package/dist/doctor/run.js +20 -0
  53. package/dist/doctor/types.d.ts +41 -0
  54. package/dist/doctor/types.js +10 -0
  55. package/dist/doctor/wrangler-config.d.ts +12 -0
  56. package/dist/doctor/wrangler-config.js +125 -0
  57. package/dist/github/branches.d.ts +11 -0
  58. package/dist/github/branches.js +75 -0
  59. package/dist/github/signing.d.ts +3 -1
  60. package/dist/github/signing.js +13 -5
  61. package/dist/log/events.d.ts +1 -1
  62. package/dist/sveltekit/content-routes.d.ts +22 -1
  63. package/dist/sveltekit/content-routes.js +320 -72
  64. package/package.json +8 -5
  65. package/src/lib/components/AdminLayout.svelte +53 -0
  66. package/src/lib/components/ComponentInsertDialog.svelte +27 -13
  67. package/src/lib/components/ConceptList.svelte +22 -3
  68. package/src/lib/components/DeleteDialog.svelte +18 -7
  69. package/src/lib/components/EditPage.svelte +604 -75
  70. package/src/lib/components/EditorToolbar.svelte +206 -29
  71. package/src/lib/components/LinkPicker.svelte +14 -6
  72. package/src/lib/components/MarkdownEditor.svelte +80 -34
  73. package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
  74. package/src/lib/components/RenameDialog.svelte +13 -4
  75. package/src/lib/components/WebLinkDialog.svelte +89 -0
  76. package/src/lib/components/cairn-admin.css +26 -4
  77. package/src/lib/components/editor-highlight.ts +67 -0
  78. package/src/lib/components/link-completion.ts +10 -3
  79. package/src/lib/components/markdown-directives.ts +23 -0
  80. package/src/lib/components/markdown-format.ts +118 -13
  81. package/src/lib/content/pending.ts +24 -0
  82. package/src/lib/diagnostics/conditions.ts +75 -2
  83. package/src/lib/doctor/bin.ts +45 -0
  84. package/src/lib/doctor/check-send.ts +43 -0
  85. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  86. package/src/lib/doctor/checks-github.ts +63 -0
  87. package/src/lib/doctor/checks-local.ts +119 -0
  88. package/src/lib/doctor/cloudflare-api.ts +33 -0
  89. package/src/lib/doctor/index.ts +93 -0
  90. package/src/lib/doctor/report.ts +30 -0
  91. package/src/lib/doctor/run.ts +23 -0
  92. package/src/lib/doctor/types.ts +52 -0
  93. package/src/lib/doctor/wrangler-config.ts +142 -0
  94. package/src/lib/github/branches.ts +83 -0
  95. package/src/lib/github/signing.ts +13 -6
  96. package/src/lib/log/events.ts +4 -0
  97. package/src/lib/sveltekit/content-routes.ts +400 -73
@@ -2,18 +2,31 @@
2
2
  @component
3
3
  The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
4
4
  markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
5
- `?/save` action; the preview toggle persists per user in localStorage (spec §7.6).
5
+ `?/save` action. The title field is hoisted above the editor card as the document title; the
6
+ remaining fields group in the sidebar under Details, Visibility (the draft boolean as the Hidden
7
+ toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
8
+ swap the editing surface for the rendered preview inside the same card; every visit lands on
9
+ Write. A sticky glass header carries the breadcrumb, the status badges, the save-state indicator,
10
+ and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
11
+ pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
12
+ transient flashes, and the editor card's footer holds the word count and the Markdown help.
6
13
  -->
7
14
  <script lang="ts">
8
15
  import { untrack } from 'svelte';
16
+ import { beforeNavigate } from '$app/navigation';
17
+ import { page } from '$app/state';
9
18
  import CsrfField from './CsrfField.svelte';
10
19
  import MarkdownEditor from './MarkdownEditor.svelte';
11
- import ComponentInsertDialog from './ComponentInsertDialog.svelte';
20
+ import EditorToolbar from './EditorToolbar.svelte';
21
+ import ComponentInsertDialog, { insertableDefs } from './ComponentInsertDialog.svelte';
12
22
  import LinkPicker from './LinkPicker.svelte';
23
+ import WebLinkDialog from './WebLinkDialog.svelte';
13
24
  import DeleteDialog from './DeleteDialog.svelte';
14
25
  import RenameDialog from './RenameDialog.svelte';
26
+ import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
15
27
  import { cairnLinkCompletionSource } from './link-completion.js';
16
- import { unwrapCairnLink } from './markdown-format.js';
28
+ import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
29
+ import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
17
30
  import type { ComponentRegistry } from '../render/registry.js';
18
31
  import type { IconSet } from '../render/glyph.js';
19
32
  import type { EditData } from '../sveltekit/content-routes.js';
@@ -46,10 +59,146 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
46
59
  // True from the moment the save form submits until the navigation it triggers replaces the page,
47
60
  // so the Save button shows a calm "Saving…" state instead of looking inert.
48
61
  let saving = $state(false);
49
- let showPreview = $state(false);
62
+ // The same working state for the Publish button, which rides the edit form via formaction. The
63
+ // submit handler reads the submitter to flip the right one, so Save never reads "Saving…" while
64
+ // a publish is in flight.
65
+ let publishing = $state(false);
66
+ function onEditSubmit(e: SubmitEvent) {
67
+ const formaction = (e.submitter as HTMLButtonElement | null)?.getAttribute('formaction');
68
+ if (formaction === '?/publish') publishing = true;
69
+ else saving = true;
70
+ }
71
+ // Either in-flight submit disables both buttons, so a second click cannot fire a second POST
72
+ // while the first navigation is still pending.
73
+ const busy = $derived(saving || publishing);
74
+ // True once a non-edit POST (discard, delete, rename) submits. Those forms navigate the
75
+ // document without flipping busy, so without this the leave guard would fire mid-discard while
76
+ // the page is still dirty, which is the primary discard scenario.
77
+ let leaving = $state(false);
78
+
79
+ // Dirty tracking. The body compares against the text the page loaded with (or the edited body a
80
+ // blocked save returned, which seeded the editor); the uncontrolled sidebar fields flip a flag
81
+ // on any input event, and the navigation a save triggers reloads the page, which resets both.
82
+ const bodyDirty = $derived(body !== (form?.body ?? data.body));
83
+ let fieldsDirty = $state(false);
84
+ const dirty = $derived(bodyDirty || fieldsDirty);
85
+ // What the header's save-state indicator says.
86
+ const saveState = $derived(dirty ? 'Unsaved changes' : data.saved ? 'Saved' : '');
87
+ function onFormInput(e: Event) {
88
+ const target = e.target as Element | null;
89
+ // Two kinds of input event bubble through the form without being frontmatter edits: the link
90
+ // picker's search box (its dialog sits in the toolbar snippet) and the editing surface's
91
+ // contenteditable. Skipping the surface keeps body edits owned by bodyDirty, so undoing back
92
+ // to the committed text reads clean again.
93
+ if (target?.closest('dialog, #cairn-pane-write')) return;
94
+ fieldsDirty = true;
95
+ }
96
+
97
+ // The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
98
+ let editForm = $state<HTMLFormElement | null>(null);
99
+
100
+ // The SvelteKit half of the leave guard. Registered at component init (beforeNavigate wraps
101
+ // onMount, so it must run synchronously here) and auto-unregistered on destroy. A submit's own
102
+ // navigation passes through because busy flips before it starts, and a non-edit POST's because
103
+ // leaving does.
104
+ beforeNavigate((navigation) => {
105
+ // A full-page unload (refresh, tab close, external link): per SvelteKit semantics, cancel()
106
+ // on a leave navigation is what asks the browser for its native dialog, so no confirm()
107
+ // here or two prompts would stack. The beforeunload listener below is deliberate
108
+ // belt-and-braces, not the dialog's source.
109
+ if (navigation.willUnload) {
110
+ if (dirty && !busy && !leaving) navigation.cancel();
111
+ return;
112
+ }
113
+ if (dirty && !busy && !leaving && !confirm('You have unsaved changes. Leave anyway?'))
114
+ navigation.cancel();
115
+ });
116
+
117
+ // The browser half of the leave guard plus the page-wide save shortcut. The handlers read the
118
+ // current dirty and busy values at event time, so the effect itself tracks nothing and runs once.
119
+ $effect(() => {
120
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
121
+ if (dirty && !busy && !leaving) e.preventDefault();
122
+ };
123
+ // Guard-clause style on purpose: svelte 5.56.1 misprints `(a || b) && c` by dropping the
124
+ // parentheses, and consumers compile this source with their own svelte.
125
+ const onWindowKeydown = (e: KeyboardEvent) => {
126
+ if (!(e.ctrlKey || e.metaKey)) return;
127
+ if (e.key.toLowerCase() !== 's') return;
128
+ // Always claim the shortcut so the browser's save-page dialog never opens over the admin.
129
+ e.preventDefault();
130
+ // Gate the submit itself: an in-flight POST must not race a second one, a clean page has
131
+ // nothing to save (a no-op save would still cut a pending branch), and a save from inside
132
+ // an open modal would act on a surface the author cannot see.
133
+ if (busy) return;
134
+ if (!dirty && !data.isNew) return;
135
+ if ((e.target as Element | null)?.closest?.('dialog')) return;
136
+ editForm?.requestSubmit();
137
+ };
138
+ window.addEventListener('beforeunload', onBeforeUnload);
139
+ window.addEventListener('keydown', onWindowKeydown);
140
+ return () => {
141
+ window.removeEventListener('beforeunload', onBeforeUnload);
142
+ window.removeEventListener('keydown', onWindowKeydown);
143
+ };
144
+ });
145
+ // The discard confirm, on the DeleteDialog pattern: a native <dialog> holding the POST form.
146
+ let discardDialog = $state<HTMLDialogElement | null>(null);
147
+ // Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
148
+ // landing tab.
149
+ let mode = $state<'write' | 'preview'>('write');
50
150
  let previewHtml = $state('');
151
+ // True after a render call threw, so the preview pane can say so instead of going blank.
152
+ let previewFailed = $state(false);
51
153
  let insert = $state.raw<(text: string) => void>(() => {});
52
154
  let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
155
+ // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
156
+ // reads it for the Text field's default.
157
+ let getSelection = $state.raw<() => string>(() => '');
158
+ // The editor's selection transform, registered by MarkdownEditor on mount; a no-op until then.
159
+ let format = $state.raw<(kind: FormatKind) => void>(() => {});
160
+ // A headless dialog instance, typed structurally over its exported open() (the linkPicker idiom).
161
+ type DialogHandle = { open: () => void };
162
+ // The toolbar's insert dialogs. Each holds its own <form>, so they mount outside the edit form
163
+ // (a form nested in a form is invalid HTML the parser repairs by dropping the outer tag, which
164
+ // breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
165
+ let webLinkDialog = $state<DialogHandle | null>(null);
166
+ let linkPicker = $state<DialogHandle | null>(null);
167
+ let insertDialog = $state<DialogHandle | null>(null);
168
+ // The lifecycle dialogs, opened from the header's overflow menu.
169
+ let deleteDialog = $state<DialogHandle | null>(null);
170
+ let renameDialog = $state<DialogHandle | null>(null);
171
+ // The Markdown cheat sheet, opened from the editor card's footer.
172
+ let helpDialog = $state<DialogHandle | null>(null);
173
+
174
+ // Whether the registry offers anything insertable, the same condition the insert dialog lists
175
+ // by, so the toolbar trigger and the dialog appear and disappear together.
176
+ const hasComponents = $derived(insertableDefs(registry).length > 0);
177
+
178
+ // The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
179
+ // when it has never been published); otherwise the live site matches and it reads Published.
180
+ const status = $derived.by(() => {
181
+ if (!data.pending) return 'Published';
182
+ return data.published ? 'Edited' : 'New';
183
+ });
184
+ const statusBadge = $derived.by(() => {
185
+ if (status === 'Edited') return 'badge-warning';
186
+ if (status === 'New') return 'badge-info';
187
+ return 'badge-ghost';
188
+ });
189
+
190
+ // The header overflow menu's popover element and its open state, mirrored from the toggle
191
+ // event into aria-expanded on the trigger.
192
+ let actionsMenu = $state<HTMLUListElement | null>(null);
193
+ let actionsOpen = $state(false);
194
+
195
+ // An overflow-menu pick runs its action, then dismisses the popover menu. Opening a modal
196
+ // dialog already closes an auto popover, so the explicit hide fires only when the menu is
197
+ // still up.
198
+ function pickAction(action: () => void) {
199
+ action();
200
+ if (actionsMenu?.matches(':popover-open')) actionsMenu.hidePopover();
201
+ }
53
202
 
54
203
  // The save guard's broken links, from the blocked action result. The fix unwraps a link in the
55
204
  // local body, which the bound editor reconciles, so the author re-saves clean.
@@ -74,24 +223,60 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
74
223
  // A rename that hit a collision or an invalid slug returns form.renameError.
75
224
  const renameError = $derived(form?.renameError ?? '');
76
225
 
77
- // After a save that links to a draft target, the redirect carries ?drafts=<tokens>.
78
- let draftWarning = $state('');
79
- $effect(() => {
80
- const search = typeof location === 'undefined' ? '' : location.search;
81
- const drafts = new URLSearchParams(search).get('drafts');
82
- draftWarning = drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
226
+ // The entry this surface is editing. SvelteKit reuses the page component across a same-route
227
+ // navigation (the delete-refused and broken-link banners link entry to entry), so the per-entry
228
+ // state seeded at init would survive the hop and show entry A's body over entry B's data with
229
+ // the dirty indicator armed. When the identity changes, re-seed the state here; the {#key}
230
+ // block around the template remounts the DOM to match (CodeMirror with its undo history, the
231
+ // uncontrolled sidebar fields, any open dialog). The leave guard still protects the hop:
232
+ // beforeNavigate runs before the navigation completes, so it reads the old dirty value.
233
+ const entryKey = $derived(data.conceptId + '/' + data.id);
234
+ let seededKey = untrack(() => entryKey);
235
+ $effect.pre(() => {
236
+ const key = entryKey;
237
+ if (key === seededKey) return;
238
+ seededKey = key;
239
+ untrack(() => {
240
+ body = form?.body ?? data.body;
241
+ saving = false;
242
+ publishing = false;
243
+ leaving = false;
244
+ fieldsDirty = false;
245
+ mode = 'write';
246
+ previewHtml = '';
247
+ previewFailed = false;
248
+ removedLinks = [];
249
+ });
83
250
  });
84
251
 
85
- // One persistent live region announces the current message, since a {#if}-gated role element
86
- // inserted fresh is announced inconsistently. A polite region carries the success and draft
87
- // notices; an assertive region carries the errors. The visible banners below keep their styling
88
- // but drop their roles, so a message is announced once.
89
- const politeMessage = $derived.by(() => {
90
- if (draftWarning) return `Saved. This page links to unpublished pages: ${draftWarning}.`;
91
- if (data.saved) return 'Saved.';
252
+ // After a save that links to a draft target, the redirect carries ?drafts=<tokens>. page.url
253
+ // is reactive kit state, so a client-side navigation that swaps the search string re-derives
254
+ // this, and the read is SSR-safe.
255
+ const draftWarning = $derived.by(() => {
256
+ const drafts = page.url.searchParams.get('drafts');
257
+ return drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
258
+ });
259
+
260
+ // The one transient feedback strip under the sticky header. The redirect flags are mutually
261
+ // exclusive in practice; the chain picks one so a surprise overlap still renders a single strip.
262
+ // A saved flash with a draft warning yields to the warning alert below, the prior behavior.
263
+ const flash = $derived.by(() => {
264
+ if (data.saved && !draftWarning)
265
+ return 'Saved. Your site keeps showing the published version until you publish.';
266
+ if (data.publishedFlash) return 'Published. The live site is rebuilding.';
267
+ if (data.discardedFlash) return 'Changes discarded.';
92
268
  if (data.renamed) return `The URL is now ${data.slug}.`;
93
269
  return '';
94
270
  });
271
+
272
+ // One persistent live region announces the current message, since a {#if}-gated role element
273
+ // inserted fresh is announced inconsistently. A polite region carries the success and draft
274
+ // notices (the flash, plus the draft notice the strip yields to); an assertive region carries
275
+ // the errors. The visible banners below keep their styling but drop their roles, so a message
276
+ // is announced once.
277
+ const politeMessage = $derived(
278
+ draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash,
279
+ );
95
280
  const assertiveMessage = $derived.by(() => {
96
281
  if (data.error) return data.error;
97
282
  if (renameError) return renameError;
@@ -106,6 +291,34 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
106
291
  return '';
107
292
  });
108
293
 
294
+ // One line of body text reduced to its prose: inline directives drop wholesale, then the
295
+ // markdown marker characters become spaces. Spacing rather than deleting keeps "[text](url)"
296
+ // as two words instead of mashing the link text into its destination, so a link counts its
297
+ // text plus its URL and the count never undercounts prose.
298
+ function proseOnly(line: string): string {
299
+ let out = '';
300
+ let cursor = 0;
301
+ for (const { from, to } of findInlineDirectives(line)) {
302
+ out += line.slice(cursor, from);
303
+ cursor = to;
304
+ }
305
+ out += line.slice(cursor);
306
+ return out.replace(/[*_~`[\]()#]/g, ' ');
307
+ }
308
+
309
+ // The editor footer's word count, over the local body so it tracks every keystroke. Directive
310
+ // machinery lines and table rows are dropped first and the inline syntax stripped, so the
311
+ // count reads as the author's prose.
312
+ const countedBody = $derived(
313
+ body
314
+ .split('\n')
315
+ .filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line))
316
+ .map(proseOnly)
317
+ .join('\n'),
318
+ );
319
+ const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
320
+ const wordLabel = $derived(wordCount === 1 ? '1 word' : `${wordCount} words`);
321
+
109
322
  // The manifest-backed resolver turns a cairn: link into its live permalink in the preview, and
110
323
  // returns undefined for a missing target so the render step marks it cairn-broken-link.
111
324
  const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
@@ -113,16 +326,38 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
113
326
  // The [[ autocomplete source over the same link targets, handed to the editor's generic seam.
114
327
  const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
115
328
 
116
- const PREVIEW_KEY = 'cairn-admin:preview';
329
+ function setMode(m: 'write' | 'preview') {
330
+ mode = m;
331
+ }
332
+
333
+ // Preview is read-only, so the insert controls the page renders into the toolbar disable with
334
+ // the strip's own format buttons.
335
+ const insertDisabled = $derived(mode === 'preview');
117
336
 
337
+ // The editor card's keyboard shortcuts. Bound to the card so they fire wherever focus sits in the
338
+ // strip or the surface, without claiming the keys page-wide. The listener attaches
339
+ // programmatically: it is event delegation, not an interaction affordance, which Svelte's a11y
340
+ // rule cannot tell apart on a declarative handler.
341
+ let editorCard = $state<HTMLDivElement | null>(null);
118
342
  $effect(() => {
119
- // Restore the per-user preference once, on mount.
120
- showPreview = localStorage.getItem(PREVIEW_KEY) === '1';
343
+ const card = editorCard;
344
+ if (!card) return;
345
+ card.addEventListener('keydown', onEditorKeydown);
346
+ return () => card.removeEventListener('keydown', onEditorKeydown);
121
347
  });
122
-
123
- function togglePreview() {
124
- showPreview = !showPreview;
125
- localStorage.setItem(PREVIEW_KEY, showPreview ? '1' : '0');
348
+ function onEditorKeydown(e: KeyboardEvent) {
349
+ if (!(e.ctrlKey || e.metaKey)) return;
350
+ const key = e.key.toLowerCase();
351
+ if (key === 'b') {
352
+ e.preventDefault();
353
+ format('bold');
354
+ } else if (key === 'i') {
355
+ e.preventDefault();
356
+ format('italic');
357
+ } else if (key === 'k') {
358
+ e.preventDefault();
359
+ webLinkDialog?.open();
360
+ }
126
361
  }
127
362
 
128
363
  // Render the design-accurate preview as the body changes, debounced. The site's render is the
@@ -131,16 +366,22 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
131
366
  // async render call resolves after a newer one has started, the stale result is discarded.
132
367
  let previewRun = 0;
133
368
  $effect(() => {
134
- if (!showPreview || !render) return;
369
+ if (mode !== 'preview' || !render) return;
135
370
  const md = body;
136
371
  const resolve = resolveLink; // tracked read in the effect body
137
372
  const run = ++previewRun;
138
373
  const handle = setTimeout(async () => {
139
374
  try {
140
375
  const html = await render(md, { resolve });
141
- if (run === previewRun) previewHtml = html;
376
+ if (run === previewRun) {
377
+ previewHtml = html;
378
+ previewFailed = false;
379
+ }
142
380
  } catch {
143
- if (run === previewRun) previewHtml = '';
381
+ if (run === previewRun) {
382
+ previewHtml = '';
383
+ previewFailed = true;
384
+ }
144
385
  }
145
386
  }, 150);
146
387
  return () => clearTimeout(handle);
@@ -150,38 +391,140 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
150
391
  function str(v: unknown): string {
151
392
  return v == null ? '' : String(v);
152
393
  }
394
+
395
+ // The eyebrow legend each sidebar group opens with, one class string for all three.
396
+ const eyebrowClass =
397
+ 'mb-2 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
398
+
399
+ // The sidebar's grouping. The title field hoists above the editor card as the document title,
400
+ // and a boolean named draft becomes the Visibility group's Hidden toggle (both production
401
+ // adapters use that name); everything else is a Details field.
402
+ const titleField = $derived(data.fields.find((f) => f.name === 'title'));
403
+ const draftField = $derived(data.fields.find((f) => f.type === 'boolean' && f.name === 'draft'));
404
+ const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
153
405
  </script>
154
406
 
155
- <header class="mb-6 flex items-center justify-between gap-2">
156
- <div>
157
- <h1 class="text-2xl font-bold tracking-tight font-[family-name:var(--font-display)]">{data.title}</h1>
158
- <p class="text-xs text-[var(--color-muted)]">{data.label}: {data.id}</p>
159
- </div>
160
- <div class="flex items-center gap-2">
161
- <ComponentInsertDialog {registry} {insert} {icons} />
162
- <LinkPicker linkTargets={data.linkTargets} insert={insertLink} />
163
- <RenameDialog conceptId={data.conceptId} id={data.id} label={data.label} slug={data.slug} />
164
- <DeleteDialog conceptId={data.conceptId} id={data.id} label={data.label} inboundLinks={data.inboundLinks} />
165
- <button
166
- type="button"
167
- class="btn btn-sm btn-ghost"
168
- aria-expanded={showPreview}
169
- aria-controls="cairn-preview"
170
- onclick={togglePreview}
171
- >
172
- {showPreview ? 'Hide preview' : 'Show preview'}
173
- </button>
407
+ <!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
408
+ reset above); script-level state and the beforeNavigate registration sit outside the block,
409
+ so only the template rebuilds. -->
410
+ {#key entryKey}
411
+ <!-- The form's default button. The header's Publish (while pending) and Save submit the edit form
412
+ from outside it, and the default button for implicit submission (Enter in a single-line
413
+ field) is the FIRST form-owned submit button in tree order, which the header's Publish would
414
+ otherwise be: Enter in the title would publish a half-finished edit. This sr-only button sits
415
+ before the header, carries no formaction (so an implicit submit posts ?/save), and mirrors
416
+ Save's disabled state, so Enter on a clean page submits nothing. -->
417
+ <button
418
+ type="submit"
419
+ form="cairn-edit-form"
420
+ class="sr-only"
421
+ tabindex="-1"
422
+ aria-hidden="true"
423
+ disabled={busy || (!dirty && !data.isNew)}
424
+ >
425
+ Save
426
+ </button>
427
+
428
+ <!-- The sticky action header, a glass ruler: a translucent base-200 veil with backdrop blur the
429
+ page scrolls beneath, never a second opaque band (the admin topbar keeps that role). It sticks
430
+ under the h-16 topbar and bleeds across AdminLayout's content padding (p-4, lg:p-8) with
431
+ matching negative margins, so the veil spans the whole content column. -->
432
+ <header
433
+ class="sticky top-16 z-10 -mx-4 mb-6 border-b border-[var(--cairn-card-border)] bg-base-200/90 px-4 py-3 backdrop-blur lg:-mx-8 lg:px-8"
434
+ >
435
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-2">
436
+ <div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
437
+ <a
438
+ href={`/admin/${data.conceptId}`}
439
+ class="flex shrink-0 items-center gap-0.5 text-sm text-[var(--color-muted)] transition-colors hover:text-base-content"
440
+ >
441
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
442
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m15 18-6-6 6-6" />
443
+ </svg>
444
+ {data.label}
445
+ </a>
446
+ <!-- The manuscript heading below is the visible title; repeating it here read as
447
+ duplication, so the header keeps the h1 for assistive tech only. -->
448
+ <h1 class="sr-only">{data.title}</h1>
449
+ <span class="badge badge-sm font-medium {statusBadge}">{status}</span>
450
+ {#if data.frontmatter.draft === true}
451
+ <span class="badge badge-neutral badge-sm font-medium">Hidden</span>
452
+ {/if}
453
+ <!-- The save-state indicator eases in and out; the admin sheet's prefers-reduced-motion rule
454
+ squashes the transition for editors who asked for that. The dot is the quiet unsaved cue. -->
455
+ <span
456
+ class="cairn-save-state flex items-center gap-1.5 text-xs text-[var(--color-muted)] transition-opacity duration-300"
457
+ class:opacity-0={!saveState}
458
+ aria-live="off"
459
+ >
460
+ {#if dirty}<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true"></span>{/if}
461
+ {saveState}
462
+ </span>
463
+ </div>
464
+ <div class="ml-auto flex items-center gap-2">
465
+ <!-- The overflow menu is a DaisyUI v5 popover dropdown: click to open (never
466
+ focus-in-transit), Escape and light dismiss from the Popover API, and the
467
+ anchor-name/position-anchor pair places the panel under its trigger. -->
468
+ <button
469
+ type="button"
470
+ class="btn btn-ghost btn-sm btn-square"
471
+ aria-label="More actions"
472
+ title="More actions"
473
+ aria-expanded={actionsOpen}
474
+ popovertarget="cairn-edit-actions-menu"
475
+ style="anchor-name:--cairn-edit-actions"
476
+ >
477
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
478
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h.01" />
479
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 12h.01" />
480
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 12h.01" />
481
+ </svg>
482
+ </button>
483
+ <ul
484
+ bind:this={actionsMenu}
485
+ popover="auto"
486
+ id="cairn-edit-actions-menu"
487
+ style="position-anchor:--cairn-edit-actions"
488
+ ontoggle={(e) => (actionsOpen = e.newState === 'open')}
489
+ class="dropdown dropdown-end menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
490
+ >
491
+ {#if data.pending}
492
+ <li>
493
+ <button type="button" aria-haspopup="dialog" onclick={() => pickAction(() => discardDialog?.showModal())}>
494
+ Discard changes
495
+ </button>
496
+ </li>
497
+ {/if}
498
+ <li>
499
+ <button type="button" class="text-error" aria-haspopup="dialog" onclick={() => pickAction(() => deleteDialog?.open())}>
500
+ Delete
501
+ </button>
502
+ </li>
503
+ </ul>
504
+ {#if data.pending}
505
+ <!-- Outline keeps Save the single solid primary action; Publish reads as its peer. -->
506
+ <button type="submit" form="cairn-edit-form" formaction="?/publish" class="btn btn-outline btn-primary btn-sm" disabled={busy}>
507
+ {#if publishing}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Publishing…{:else}Publish{/if}
508
+ </button>
509
+ {/if}
510
+ <!-- Save sleeps while the page is clean, agreeing with the header indicator; a new entry
511
+ stays saveable so it can be created as loaded. -->
512
+ <button type="submit" form="cairn-edit-form" class="btn btn-primary btn-sm" disabled={busy || (!dirty && !data.isNew)}>
513
+ {#if saving}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Saving…{:else}Save{/if}
514
+ </button>
515
+ </div>
174
516
  </div>
175
517
  </header>
176
518
 
177
519
  <div class="sr-only" aria-live="polite">{politeMessage}</div>
178
520
  <div class="sr-only" aria-live="assertive">{assertiveMessage}</div>
179
521
 
180
- {#if data.saved && !draftWarning}
181
- <div class="alert alert-success mb-4 text-sm">Saved.</div>
182
- {/if}
183
- {#if data.renamed}
184
- <div class="alert alert-success mb-4 text-sm">The URL is now {data.slug}.</div>
522
+ <!-- The feedback strip slides in just under the header: @starting-style drives the entry, so the
523
+ motion is pure CSS and the admin sheet's prefers-reduced-motion rule squashes it. -->
524
+ {#if flash}
525
+ <div class="cairn-feedback alert alert-success mb-4 text-sm transition-all duration-300 starting:-translate-y-2 starting:opacity-0">
526
+ {flash}
527
+ </div>
185
528
  {/if}
186
529
  {#if data.error}
187
530
  <div class="alert alert-error mb-4 text-sm">{data.error}</div>
@@ -221,35 +564,142 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
221
564
  </div>
222
565
  {/if}
223
566
 
224
- <form method="POST" action="?/save" onsubmit={() => (saving = true)} class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6">
567
+ <form
568
+ method="POST"
569
+ action="?/save"
570
+ id="cairn-edit-form"
571
+ bind:this={editForm}
572
+ onsubmit={onEditSubmit}
573
+ oninput={onFormInput}
574
+ class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6"
575
+ >
225
576
  <CsrfField />
226
577
  {#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
227
578
 
228
579
  <div class="lg:order-1">
229
- <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]">
230
- <MarkdownEditor
231
- bind:value={body}
232
- name="body"
233
- registerInsert={(fn) => (insert = fn)}
234
- registerInsertLink={(fn) => (insertLink = fn)}
235
- {completionSources}
580
+ {#if titleField}
581
+ <!-- The hoisted document title: large, borderless, in the display face, so the manuscript
582
+ reads as the protagonist. It submits as name="title", the same field as before. The
583
+ admin sheet gives it the editor's quiet focus hairline (see .cairn-doc-title there). -->
584
+ <input
585
+ class="cairn-doc-title mb-4 w-full border-0 bg-transparent text-3xl font-bold tracking-tight font-[family-name:var(--font-display)] placeholder:text-[var(--color-muted)]"
586
+ name="title"
587
+ value={str(data.frontmatter.title)}
588
+ placeholder={titleField.label}
589
+ aria-label={titleField.label}
590
+ required={titleField.required}
236
591
  />
237
- </div>
238
- {#if showPreview}
239
- <section
240
- id="cairn-preview"
241
- aria-label="Preview"
242
- class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 prose mt-4 max-w-none p-4 shadow-[var(--cairn-shadow)]"
243
- >
244
- {@html previewHtml}
245
- </section>
246
592
  {/if}
593
+ <!-- The editor card: the toolbar strip and the editing surface share one frame, so the editor
594
+ reads as a single object. The card carries the formatting shortcuts for everything in it. -->
595
+ <div
596
+ bind:this={editorCard}
597
+ class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]"
598
+ role="group"
599
+ aria-label="Editor"
600
+ >
601
+ <EditorToolbar {format} {mode} onMode={setMode}>
602
+ {#snippet insertControls()}
603
+ <!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
604
+ dialogs themselves mount outside the edit form at the bottom of this component. -->
605
+ {#if hasComponents}
606
+ <button
607
+ type="button"
608
+ class="btn btn-sm btn-ghost"
609
+ aria-haspopup="dialog"
610
+ aria-label="Insert block"
611
+ disabled={insertDisabled}
612
+ onclick={() => insertDialog?.open()}
613
+ >
614
+ Insert block
615
+ </button>
616
+ {/if}
617
+ <button
618
+ type="button"
619
+ class="btn btn-sm btn-ghost"
620
+ aria-haspopup="dialog"
621
+ aria-label="Web link (Ctrl+K)"
622
+ title="Web link (Ctrl+K)"
623
+ disabled={insertDisabled}
624
+ onclick={() => webLinkDialog?.open()}
625
+ >
626
+ Web link
627
+ </button>
628
+ <button
629
+ type="button"
630
+ class="btn btn-sm btn-ghost"
631
+ aria-haspopup="dialog"
632
+ aria-label="Link to page"
633
+ disabled={insertDisabled}
634
+ onclick={() => linkPicker?.open()}
635
+ >
636
+ Link to page
637
+ </button>
638
+ <button
639
+ type="button"
640
+ class="btn btn-ghost btn-sm btn-square"
641
+ disabled
642
+ aria-label="Image (coming soon)"
643
+ title="Image (coming soon)"
644
+ >
645
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
646
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
647
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7a2 2 0 1 0 0 4 2 2 0 0 0 0-4z" />
648
+ <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" />
649
+ </svg>
650
+ </button>
651
+ {/snippet}
652
+ </EditorToolbar>
653
+ <!-- The Write pane stays mounted while Preview shows, so CodeMirror keeps its caret, scroll
654
+ position, and undo history across the tab switch. -->
655
+ <div id="cairn-pane-write" role="tabpanel" aria-labelledby="cairn-tab-write" class:hidden={mode === 'preview'}>
656
+ <MarkdownEditor
657
+ bind:value={body}
658
+ name="body"
659
+ registerInsert={(fn) => (insert = fn)}
660
+ registerInsertLink={(fn) => (insertLink = fn)}
661
+ registerGetSelection={(fn) => (getSelection = fn)}
662
+ registerFormat={(fn) => (format = fn)}
663
+ {completionSources}
664
+ />
665
+ </div>
666
+ {#if mode === 'preview'}
667
+ <!-- tabindex 0: the pane holds no focusable content, so it is itself a tab stop (the
668
+ tabpanel pattern's completeness requirement). -->
669
+ <div id="cairn-pane-preview" role="tabpanel" aria-labelledby="cairn-tab-preview" tabindex="0" class="prose max-w-none p-4">
670
+ {#if previewHtml}
671
+ {@html previewHtml}
672
+ {:else if previewFailed}
673
+ <p class="text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
674
+ {:else}
675
+ <p class="text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
676
+ {/if}
677
+ </div>
678
+ {/if}
679
+ <!-- The card footer, part of the same instrument frame. It stays up in Preview too, so the
680
+ frame never jumps between tabs and the count keeps reading while proofing. -->
681
+ <div class="flex items-center justify-between border-t border-[var(--cairn-card-border)] px-3 py-1 text-xs text-[var(--color-muted)]">
682
+ <span>{wordLabel}</span>
683
+ <button
684
+ type="button"
685
+ class="btn btn-ghost btn-xs font-normal text-[var(--color-muted)]"
686
+ aria-haspopup="dialog"
687
+ onclick={() => helpDialog?.open()}
688
+ >
689
+ Markdown help
690
+ </button>
691
+ </div>
692
+ </div>
247
693
  </div>
248
694
 
249
695
  <aside class="lg:order-2 mt-4 lg:mt-0">
250
- <fieldset class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-3 p-4 shadow-[var(--cairn-shadow)]">
251
- <legend class="sr-only">Frontmatter</legend>
252
- {#each data.fields as field (field.name)}
696
+ <!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
697
+ a real legend that screen readers announce with the fields it holds. -->
698
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-5 p-4 shadow-[var(--cairn-shadow)]">
699
+ {#if detailFields.length}
700
+ <fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
701
+ <legend class={eyebrowClass}>Details</legend>
702
+ {#each detailFields as field (field.name)}
253
703
  {#if field.type === 'textarea'}
254
704
  {@const f = field as TextareaField}
255
705
  <label class="flex flex-col gap-1">
@@ -306,9 +756,88 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
306
756
  </label>
307
757
  {/if}
308
758
  {/each}
309
- <button type="submit" class="btn btn-primary mt-3" disabled={saving}>
310
- {#if saving}<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> Saving…{:else}Save{/if}
311
- </button>
312
- </fieldset>
759
+ </fieldset>
760
+ {/if}
761
+ {#if draftField}
762
+ <fieldset class="m-0 flex min-w-0 flex-col gap-1 border-0 p-0">
763
+ <legend class={eyebrowClass}>Visibility</legend>
764
+ <label class="label cursor-pointer justify-start gap-2">
765
+ <input class="checkbox checkbox-sm" type="checkbox" name="draft" checked={data.frontmatter.draft === true} />
766
+ <span class="text-sm">Hidden</span>
767
+ </label>
768
+ <p class="text-xs text-[var(--color-muted)]">Hidden entries stay off the site's lists and feeds, even when published.</p>
769
+ </fieldset>
770
+ {/if}
771
+ <fieldset class="m-0 flex min-w-0 flex-col gap-1 border-0 p-0">
772
+ <legend class={eyebrowClass}>Address</legend>
773
+ <div class="flex items-center justify-between gap-2">
774
+ <code class="min-w-0 break-all text-xs text-[var(--color-muted)]">/{data.slug}</code>
775
+ <button
776
+ type="button"
777
+ class="btn btn-ghost btn-sm shrink-0"
778
+ aria-haspopup="dialog"
779
+ onclick={() => renameDialog?.open()}
780
+ >
781
+ Change URL
782
+ </button>
783
+ </div>
784
+ </fieldset>
785
+ </div>
313
786
  </aside>
314
787
  </form>
788
+
789
+ <!-- The toolbar's insert dialogs, mounted headless outside the edit form: each holds its own
790
+ <form>, and a form nested in a form is invalid HTML the parser repairs by dropping the outer
791
+ tag, which breaks the SSR'd document and hydration. The toolbar snippet's triggers drive them
792
+ through their exported open(). -->
793
+ <ComponentInsertDialog bind:this={insertDialog} trigger={false} {registry} {insert} {icons} />
794
+ <WebLinkDialog bind:this={webLinkDialog} trigger={false} insert={insertLink} selection={getSelection} />
795
+ <LinkPicker bind:this={linkPicker} trigger={false} linkTargets={data.linkTargets} insert={insertLink} />
796
+
797
+ <!-- The lifecycle dialogs, mounted headless: the header's overflow menu drives them through their
798
+ exported open(). Their POST forms flip the leaving flag so the leave guard stands down. -->
799
+ <RenameDialog
800
+ bind:this={renameDialog}
801
+ trigger={false}
802
+ conceptId={data.conceptId}
803
+ id={data.id}
804
+ label={data.label}
805
+ slug={data.slug}
806
+ onsubmitting={() => (leaving = true)}
807
+ />
808
+ <MarkdownHelpDialog bind:this={helpDialog} />
809
+ <DeleteDialog
810
+ bind:this={deleteDialog}
811
+ trigger={false}
812
+ conceptId={data.conceptId}
813
+ id={data.id}
814
+ label={data.label}
815
+ inboundLinks={data.inboundLinks}
816
+ pending={data.pending}
817
+ onsubmitting={() => (leaving = true)}
818
+ />
819
+
820
+ {#if data.pending}
821
+ <dialog class="modal" aria-labelledby="cairn-discard-dialog-title" bind:this={discardDialog}>
822
+ <div class="modal-box">
823
+ <div class="mb-3 flex items-center justify-between">
824
+ <h2 id="cairn-discard-dialog-title" class="text-base font-semibold">Discard the unpublished changes?</h2>
825
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => discardDialog?.close()}>✕</button>
826
+ </div>
827
+ {#if data.published}
828
+ <p class="mb-3 text-sm">This restores the live version. The changes cannot be recovered.</p>
829
+ {:else}
830
+ <p class="mb-3 text-sm">This entry has never been published, so discarding deletes it. Nothing can be recovered.</p>
831
+ {/if}
832
+ <form method="POST" action="?/discard" class="flex justify-end gap-2" onsubmit={() => (leaving = true)}>
833
+ <CsrfField />
834
+ <button type="button" class="btn btn-sm" onclick={() => discardDialog?.close()}>Cancel</button>
835
+ <button type="submit" class="btn btn-sm btn-error">Discard</button>
836
+ </form>
837
+ </div>
838
+ <form method="dialog" class="modal-backdrop">
839
+ <button tabindex="-1" aria-label="Close">close</button>
840
+ </form>
841
+ </dialog>
842
+ {/if}
843
+ {/key}