@glw907/cairn-cms 0.37.1 → 0.40.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 (69) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +6 -5
  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 +13 -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 +575 -70
  10. package/dist/components/EditPage.svelte.d.ts +8 -1
  11. package/dist/components/EditorToolbar.svelte +202 -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/LoginPage.svelte +16 -4
  16. package/dist/components/LoginPage.svelte.d.ts +3 -1
  17. package/dist/components/MarkdownEditor.svelte +80 -34
  18. package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
  19. package/dist/components/MarkdownHelpDialog.svelte +58 -0
  20. package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
  21. package/dist/components/RenameDialog.svelte +13 -4
  22. package/dist/components/RenameDialog.svelte.d.ts +9 -1
  23. package/dist/components/WebLinkDialog.svelte +89 -0
  24. package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
  25. package/dist/components/cairn-admin.css +353 -4
  26. package/dist/components/editor-highlight.d.ts +9 -0
  27. package/dist/components/editor-highlight.js +62 -0
  28. package/dist/components/markdown-directives.d.ts +7 -0
  29. package/dist/components/markdown-directives.js +22 -0
  30. package/dist/components/markdown-format.d.ts +1 -1
  31. package/dist/components/markdown-format.js +91 -12
  32. package/dist/content/pending.d.ts +9 -0
  33. package/dist/content/pending.js +24 -0
  34. package/dist/diagnostics/conditions.js +16 -0
  35. package/dist/email.d.ts +20 -1
  36. package/dist/email.js +25 -0
  37. package/dist/github/branches.d.ts +11 -0
  38. package/dist/github/branches.js +75 -0
  39. package/dist/log/events.d.ts +1 -1
  40. package/dist/sveltekit/auth-routes.d.ts +16 -3
  41. package/dist/sveltekit/auth-routes.js +47 -28
  42. package/dist/sveltekit/content-routes.d.ts +22 -1
  43. package/dist/sveltekit/content-routes.js +312 -72
  44. package/dist/sveltekit/index.d.ts +1 -1
  45. package/package.json +3 -2
  46. package/src/lib/components/AdminLayout.svelte +53 -0
  47. package/src/lib/components/ComponentInsertDialog.svelte +27 -13
  48. package/src/lib/components/ConceptList.svelte +13 -3
  49. package/src/lib/components/DeleteDialog.svelte +18 -7
  50. package/src/lib/components/EditPage.svelte +575 -70
  51. package/src/lib/components/EditorToolbar.svelte +202 -29
  52. package/src/lib/components/LinkPicker.svelte +14 -6
  53. package/src/lib/components/LoginPage.svelte +16 -4
  54. package/src/lib/components/MarkdownEditor.svelte +80 -34
  55. package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
  56. package/src/lib/components/RenameDialog.svelte +13 -4
  57. package/src/lib/components/WebLinkDialog.svelte +89 -0
  58. package/src/lib/components/cairn-admin.css +26 -4
  59. package/src/lib/components/editor-highlight.ts +67 -0
  60. package/src/lib/components/markdown-directives.ts +23 -0
  61. package/src/lib/components/markdown-format.ts +118 -13
  62. package/src/lib/content/pending.ts +24 -0
  63. package/src/lib/diagnostics/conditions.ts +16 -0
  64. package/src/lib/email.ts +31 -1
  65. package/src/lib/github/branches.ts +83 -0
  66. package/src/lib/log/events.ts +3 -0
  67. package/src/lib/sveltekit/auth-routes.ts +59 -29
  68. package/src/lib/sveltekit/content-routes.ts +391 -73
  69. package/src/lib/sveltekit/index.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,77 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.40.0
6
+
7
+ The edit page is redesigned around the manuscript. A sticky translucent header carries the
8
+ breadcrumb, the status badge (New, Edited, or Published, with Hidden beside it when the `draft`
9
+ flag is set), an unsaved-changes indicator, Publish and Save, and an overflow menu holding Discard
10
+ changes and Delete. The editor sits in one card frame: a full GFM toolbar (bold, italic, two
11
+ heading levels, lists, quote, and a More menu with strikethrough, inline code, code block, a table
12
+ starter, horizontal rule, and task list), the writing surface, and a footer with a word count and
13
+ a Markdown help cheat sheet. Write/Preview tabs replace the stacked preview. When the schema
14
+ declares a `title` field, the document title hoists above the card, and the sidebar groups into
15
+ Details, Visibility (the `draft` flag as the Hidden toggle), and Address (the slug beside a Change
16
+ URL button). On the surface itself: markdown syntax highlighting in the admin palette, a soft
17
+ accent band with a plain-language tooltip on `:::` directive machinery, and native browser spell
18
+ check.
19
+ Ctrl/Cmd+B and Ctrl/Cmd+I format the selection, Ctrl/Cmd+K opens a new web-link dialog,
20
+ Ctrl/Cmd+S saves, and leaving the page with unsaved edits asks first.
21
+
22
+ The component surface grows additively. `MarkdownEditor` gains `registerFormat` and
23
+ `registerGetSelection`, and it no longer renders its own toolbar or card chrome; the host frames
24
+ it, and `EditPage` does. `DeleteDialog` and `RenameDialog` gain an exported `open()` and a
25
+ `trigger` prop, `LinkPicker` gains an exported `open()` and a `disabled` prop, and
26
+ `ComponentInsertDialog` gains `disabled`. The light theme's `--color-accent` darkened to
27
+ `oklch(54% 0.16 300)` so the editor's directive ink holds AA contrast.
28
+
29
+ Consumers must: nothing for a site mounting the admin through the route factories and `EditPage`;
30
+ no shim, action, or load changes. A site that renders `MarkdownEditor` directly, outside
31
+ `EditPage`, no longer gets an embedded toolbar or card frame; it may host its own controls through
32
+ the new `registerFormat` seam or accept the plain surface. One advisory for every consumer: sites
33
+ compile the shipped `.svelte` sources, and svelte `5.56.1` has a compiler bug that misprints
34
+ parenthesized boolean groupings, so use svelte `5.56.3` or newer. The editor-facing walkthrough is
35
+ [the write-in-the-editor guide](docs/guides/write-in-the-editor.md).
36
+
37
+ ## 0.39.0
38
+
39
+ Content edits are now held until a deliberate Publish. A save commits to the entry's pending
40
+ branch, `cairn/<concept>/<id>`, cut lazily from the default branch's head, and the live site does
41
+ not change. The per-page Publish validates and holds the posted form like a save, then commits
42
+ that markdown to the default branch, with its manifest row upserted, in one commit; that commit
43
+ triggers the deploy. The pending branch is then deleted, guarded by a head-sha check so a save
44
+ landing mid-publish is never destroyed. A
45
+ site-wide "Publish site (N)" action in the admin topbar ships every pending entry in one atomic
46
+ commit. Discard deletes the pending branch, restoring the live version of a published entry or
47
+ removing a never-published one entirely. The ref's existence is the only pending state; there is
48
+ no metadata file and no database row.
49
+
50
+ The admin shows the new state everywhere. List rows carry a status badge (New, Edited, or
51
+ Published), with the `draft:` flag re-presented as a separate Hidden badge whose mechanics are
52
+ unchanged. The edit page gains a pending banner, a Publish button, and a Discard changes confirm.
53
+ Deleting an entry cascades to its pending branch, and renaming is refused while one exists.
54
+ `EntrySummary`, `ListData`, `EditData`, and `LayoutData` widen accordingly, `createContentRoutes`
55
+ returns the three new actions, and three log events join the vocabulary (`entry.published`,
56
+ `entry.discarded`, `publish.failed`), with `commit.succeeded`/`commit.failed` carrying a `branch`
57
+ field on the save path.
58
+
59
+ Consumers must: add publish/discard to the edit shim's actions and publishAll to the list shim's actions; saves no longer deploy the site, Publish does.
60
+ The exact lines are in
61
+ [the upgrade guide](docs/guides/upgrade-cairn.md) and
62
+ [the admin route structure](docs/reference/admin-routes.md). The editor-facing walkthrough is
63
+ [the publish and discard guide](docs/guides/publish-and-discard.md).
64
+
65
+ ## 0.38.0
66
+
67
+ The magic-link send is now awaited rather than fire-and-forget, so a delivery failure reaches the
68
+ login response instead of being swallowed. `requestAction` returns a `status` discriminant
69
+ (`sent` | `send_error` | `throttled`) alongside the existing `sent` boolean, and `LoginPage` renders
70
+ a send-error and a throttled state. The `auth.link.send_failed` log record gains a `code` (the
71
+ Cloudflare binding error code) and a `conditionId` (the mapped diagnostic condition).
72
+
73
+ Consumers may: read `form.status` to render the new states. A site rendering against `form.sent` is
74
+ unaffected, since `sent` is unchanged.
75
+
5
76
  ## 0.37.1
6
77
 
7
78
  Internal groundwork and a docs overhaul; nothing in the public surface or runtime behavior
package/README.md CHANGED
@@ -4,11 +4,12 @@ A CMS that lives inside your SvelteKit site and commits to git. Your editors log
4
4
  email link (no GitHub account, no password), write raw markdown in a CodeMirror editor with a
5
5
  live preview, and hit Save.
6
6
 
7
- When they hit Save, cairn doesn't write to a database. It commits the markdown straight to
8
- the repo's `main` branch. The commit goes through a GitHub App, so the editor never touches
9
- GitHub; they still show up as the commit author, and `cairn-cms[bot]` does the signing. From
10
- there your normal Cloudflare deploy takes over, the same as if you'd pushed from a terminal.
11
- Commit is publish. If someone else changed the file mid-edit, cairn refuses the save instead
7
+ When they hit Save, cairn doesn't write to a database. It commits the markdown to a holding
8
+ branch in the repo, one branch per entry, where the edit waits until the editor hits Publish.
9
+ Publish copies the held content to `main`, and from there your normal Cloudflare deploy takes
10
+ over, the same as if you'd pushed from a terminal. Every commit goes through a GitHub App, so
11
+ the editor never touches GitHub; they still show up as the commit author, and `cairn-cms[bot]`
12
+ does the signing. If someone else changed the file mid-edit, cairn refuses the save instead
12
13
  of guessing how to merge.
13
14
 
14
15
  ## How it fits your site
@@ -124,6 +124,7 @@ identical on every host regardless of the site's own theme.
124
124
  data.pathname;
125
125
  drawerOpen = false;
126
126
  paletteDialog?.close();
127
+ publishAllDialog?.close();
127
128
  });
128
129
 
129
130
  // Seed from the SSR'd theme once. The live theme is owned by this state and the toggle, so the
@@ -158,6 +159,21 @@ identical on every host regardless of the site's own theme.
158
159
  let paletteList = $state<HTMLUListElement>();
159
160
  let paletteQuery = $state('');
160
161
 
162
+ // The site-wide publish action. The trigger and its confirm render only while entries are
163
+ // pending; a null pendingEntries (GitHub unreachable) hides them rather than showing a stale count.
164
+ let publishAllDialog = $state<HTMLDialogElement>();
165
+ const pendingCount = $derived(data.pendingEntries?.length ?? 0);
166
+ // The pending ids grouped under their concept's nav label, in first-seen order. A ref whose
167
+ // concept is not in the nav (an unconfigured key) falls back to the raw key.
168
+ const pendingGroups = $derived.by(() => {
169
+ const groups = new Map<string, string[]>();
170
+ for (const entry of data.pendingEntries ?? []) {
171
+ const label = data.concepts.find((c) => c.id === entry.concept)?.label ?? entry.concept;
172
+ groups.set(label, [...(groups.get(label) ?? []), entry.id]);
173
+ }
174
+ return [...groups.entries()].map(([label, ids]) => ({ label, ids }));
175
+ });
176
+
161
177
  // The bare data-theme wrapper is the admin root the dev chrome-guard measures from.
162
178
  let rootEl = $state<HTMLElement>();
163
179
  onMount(() => {
@@ -263,6 +279,13 @@ identical on every host regardless of the site's own theme.
263
279
  <kbd class="ml-auto hidden rounded border border-[var(--cairn-card-border)] px-1.5 text-[0.6875rem] font-medium sm:inline">&#8984;K</kbd>
264
280
  </button>
265
281
  </div>
282
+ {#if pendingCount > 0 && data.concepts.length > 0}
283
+ <div class="flex-none">
284
+ <button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => publishAllDialog?.showModal()}>
285
+ Publish site ({pendingCount})
286
+ </button>
287
+ </div>
288
+ {/if}
266
289
  <div class="flex-none">
267
290
  <button type="button" class="btn btn-square btn-ghost" aria-label="Toggle theme" onclick={toggleTheme}>
268
291
  {#if theme === 'cairn-admin'}<MoonIcon class="h-5 w-5" />{:else}<SunIcon class="h-5 w-5" />{/if}
@@ -325,6 +348,36 @@ identical on every host regardless of the site's own theme.
325
348
  </div>
326
349
  <form method="dialog" class="modal-backdrop"><button tabindex="-1" aria-label="Close">close</button></form>
327
350
  </dialog>
351
+
352
+ <!-- The form action below reads data.concepts[0], so zero configured concepts (with a stray
353
+ pending ref) must hide the dialog along with its trigger. -->
354
+ {#if pendingCount > 0 && data.concepts.length > 0}
355
+ <dialog bind:this={publishAllDialog} class="modal" aria-labelledby="cairn-publish-all-title">
356
+ <div class="modal-box">
357
+ <div class="mb-3 flex items-center justify-between">
358
+ <h2 id="cairn-publish-all-title" class="text-base font-semibold">Publish the whole site?</h2>
359
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => publishAllDialog?.close()}>✕</button>
360
+ </div>
361
+ <p class="text-sm">Every entry below goes live in one step.</p>
362
+ {#each pendingGroups as group, i (group.label)}
363
+ <p id={`cairn-publish-group-${i}`} class="mt-3 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">{group.label}</p>
364
+ <ul class="mt-1 text-sm" aria-labelledby={`cairn-publish-group-${i}`}>
365
+ {#each group.ids as id (id)}
366
+ <li>{id}</li>
367
+ {/each}
368
+ </ul>
369
+ {/each}
370
+ <!-- The publishAll action is mounted on every concept-list shim; the first concept's
371
+ route hosts the site-wide POST so the topbar works from any admin page. -->
372
+ <form method="POST" action={`/admin/${data.concepts[0].id}?/publishAll`} class="mt-4 flex justify-end gap-2">
373
+ <CsrfField token={data.csrf} />
374
+ <button type="button" class="btn btn-sm" onclick={() => publishAllDialog?.close()}>Cancel</button>
375
+ <button type="submit" class="btn btn-sm btn-primary">Publish site</button>
376
+ </form>
377
+ </div>
378
+ <form method="dialog" class="modal-backdrop"><button tabindex="-1" aria-label="Close">close</button></form>
379
+ </dialog>
380
+ {/if}
328
381
  </div>
329
382
 
330
383
  <div class="drawer-side">
@@ -5,8 +5,21 @@ intended use. A component with a schema opens the guided ComponentForm; a templa
5
5
  inserts directly; a component with neither is not listed. Built on a native <dialog> for focus
6
6
  trapping and Escape, following the dropdown's a11y conventions used elsewhere in the admin.
7
7
  -->
8
- <script lang="ts">
8
+ <script module lang="ts">
9
9
  import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
10
+
11
+ function hasSchema(def: ComponentDef): boolean {
12
+ return (def.attributes?.length ?? 0) > 0 || (def.slots?.length ?? 0) > 0;
13
+ }
14
+ /** The registry's actionable components: a schema opens the guided form, a template inserts
15
+ * directly, and a component with neither is not listed. Exported so a host rendering its own
16
+ * trigger (the edit page's toolbar) can hide it under the same condition the dialog uses. */
17
+ export function insertableDefs(registry?: ComponentRegistry): ComponentDef[] {
18
+ return (registry?.defs ?? []).filter((def) => hasSchema(def) || Boolean(def.insertTemplate));
19
+ }
20
+ </script>
21
+
22
+ <script lang="ts">
10
23
  import type { IconSet } from '../render/glyph.js';
11
24
  import ComponentForm from './ComponentForm.svelte';
12
25
 
@@ -17,23 +30,22 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
17
30
  insert: (text: string) => void;
18
31
  /** The site's icon set, for icon fields. */
19
32
  icons?: IconSet;
33
+ /** Disable the trigger; the host sets it while Preview shows. */
34
+ disabled?: boolean;
35
+ /** Render the built-in Insert block trigger. False mounts only the dialog, for a host that
36
+ * supplies its own trigger and opens the dialog through the exported open(). */
37
+ trigger?: boolean;
20
38
  }
21
39
 
22
- let { registry, insert, icons }: Props = $props();
40
+ let { registry, insert, icons, disabled = false, trigger = true }: Props = $props();
23
41
 
24
42
  let dialog = $state<HTMLDialogElement | null>(null);
25
43
  let picked = $state<ComponentDef | null>(null);
26
44
 
27
- function hasSchema(def: ComponentDef): boolean {
28
- return (def.attributes?.length ?? 0) > 0 || (def.slots?.length ?? 0) > 0;
29
- }
30
- function actionable(def: ComponentDef): boolean {
31
- return hasSchema(def) || Boolean(def.insertTemplate);
32
- }
45
+ const defs = $derived(insertableDefs(registry));
33
46
 
34
- const defs = $derived((registry?.defs ?? []).filter(actionable));
35
-
36
- function open() {
47
+ /** Open the picker. Exported so a trigger={false} host can drive the dialog itself. */
48
+ export function open() {
37
49
  picked = null;
38
50
  dialog?.showModal();
39
51
  }
@@ -55,9 +67,11 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
55
67
  }
56
68
  </script>
57
69
 
58
- {#if defs.length > 0}
59
- <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Insert component" onclick={open}>Insert</button>
70
+ {#if trigger && defs.length > 0}
71
+ <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Insert block" {disabled} onclick={open}>Insert block</button>
72
+ {/if}
60
73
 
74
+ {#if defs.length > 0}
61
75
  <dialog class="modal" aria-labelledby="cairn-insert-dialog-title" bind:this={dialog} onclose={() => (picked = null)}>
62
76
  <div class="modal-box">
63
77
  <div class="mb-3 flex items-center justify-between">
@@ -1,4 +1,8 @@
1
- import type { ComponentRegistry } from '../render/registry.js';
1
+ import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
2
+ /** The registry's actionable components: a schema opens the guided form, a template inserts
3
+ * directly, and a component with neither is not listed. Exported so a host rendering its own
4
+ * trigger (the edit page's toolbar) can hide it under the same condition the dialog uses. */
5
+ export declare function insertableDefs(registry?: ComponentRegistry): ComponentDef[];
2
6
  import type { IconSet } from '../render/glyph.js';
3
7
  interface Props {
4
8
  /** The site's component registry. */
@@ -7,6 +11,11 @@ interface Props {
7
11
  insert: (text: string) => void;
8
12
  /** The site's icon set, for icon fields. */
9
13
  icons?: IconSet;
14
+ /** Disable the trigger; the host sets it while Preview shows. */
15
+ disabled?: boolean;
16
+ /** Render the built-in Insert block trigger. False mounts only the dialog, for a host that
17
+ * supplies its own trigger and opens the dialog through the exported open(). */
18
+ trigger?: boolean;
10
19
  }
11
20
  /**
12
21
  * The Insert control and its modal. The picker lists each actionable component with its description and
@@ -14,6 +23,8 @@ interface Props {
14
23
  * inserts directly; a component with neither is not listed. Built on a native <dialog> for focus
15
24
  * trapping and Escape, following the dropdown's a11y conventions used elsewhere in the admin.
16
25
  */
17
- declare const ComponentInsertDialog: import("svelte").Component<Props, {}, "">;
26
+ declare const ComponentInsertDialog: import("svelte").Component<Props, {
27
+ open: () => void;
28
+ }, "">;
18
29
  type ComponentInsertDialog = ReturnType<typeof ComponentInsertDialog>;
19
30
  export default ComponentInsertDialog;
@@ -122,6 +122,12 @@ content sizes. The header New button opens a dialog holding the create form.
122
122
  </div>
123
123
  </header>
124
124
 
125
+ <!-- A racing second admin can publish first, leaving this redirect counting zero; say nothing then. -->
126
+ {#if data.publishedAll !== null && data.publishedAll > 0}
127
+ <div role="status" class="alert alert-success mb-4 text-sm">
128
+ Published {data.publishedAll} {data.publishedAll === 1 ? 'entry' : 'entries'}.
129
+ </div>
130
+ {/if}
125
131
  {#if data.formError}
126
132
  <div role="alert" class="alert alert-error mb-4 text-sm">{data.formError}</div>
127
133
  {/if}
@@ -194,13 +200,17 @@ content sizes. The header New button opens a dialog holding the create form.
194
200
  <td><a class="font-medium hover:text-primary hover:underline" href={`/admin/${data.conceptId}/${entry.id}`}>{entry.title}</a></td>
195
201
  {#if data.dated}<td class="hidden text-sm text-[var(--color-muted)] sm:table-cell">{formatDate(entry.date)}</td>{/if}
196
202
  <td>
197
- {#if entry.draft}<span class="badge badge-warning badge-sm font-medium">Draft</span>
198
- {:else}<span class="badge badge-ghost badge-sm font-medium">Published</span>{/if}
203
+ <div class="flex flex-wrap items-center gap-1">
204
+ {#if entry.status === 'new'}<span class="badge badge-info badge-sm font-medium">New</span>
205
+ {:else if entry.status === 'edited'}<span class="badge badge-warning badge-sm font-medium">Edited</span>
206
+ {:else}<span class="badge badge-ghost badge-sm font-medium">Published</span>{/if}
207
+ {#if entry.draft}<span class="badge badge-neutral badge-sm font-medium">Hidden</span>{/if}
208
+ </div>
199
209
  </td>
200
210
  <td class="text-right">
201
211
  {#if deleteRefused?.id === entry.id}
202
212
  <!-- A prior delete was refused: DeleteDialog names the blockers and offers no confirm. -->
203
- <DeleteDialog conceptId={data.conceptId} id={entry.id} label={data.label} inboundLinks={deleteRefused.inboundLinks} />
213
+ <DeleteDialog conceptId={data.conceptId} id={entry.id} label={data.label} inboundLinks={deleteRefused.inboundLinks} pending={entry.status !== 'published'} />
204
214
  {:else}
205
215
  <form method="POST" action="?/delete">
206
216
  <CsrfField />
@@ -18,9 +18,17 @@ each linking to its edit page, so the author repoints or removes those links fir
18
18
  label: string;
19
19
  /** The entries that link to this one; non-empty blocks the delete. */
20
20
  inboundLinks: InboundLink[];
21
+ /** True when the entry has unpublished edits, which the delete discards along with it. */
22
+ pending?: boolean;
23
+ /** Render the built-in Delete trigger. False mounts only the dialog, for a host that supplies
24
+ * its own trigger and opens the dialog through the exported open(). */
25
+ trigger?: boolean;
26
+ /** Called when the delete confirm submits, before the document navigates. The edit page uses
27
+ * it to stand down its leave guard while the POST is in flight. */
28
+ onsubmitting?: () => void;
21
29
  }
22
30
 
23
- let { conceptId, id, label, inboundLinks }: Props = $props();
31
+ let { conceptId, id, label, inboundLinks, pending = false, trigger = true, onsubmitting }: Props = $props();
24
32
 
25
33
  let dialog = $state<HTMLDialogElement | null>(null);
26
34
  const blocked = $derived(inboundLinks.length > 0);
@@ -32,7 +40,8 @@ each linking to its edit page, so the author repoints or removes those links fir
32
40
  const verb = $derived(single ? 'links' : 'link');
33
41
  const pronoun = $derived(single ? 'it' : 'them');
34
42
 
35
- function open() {
43
+ /** Open the confirm. Exported so a trigger={false} host can drive the dialog itself. */
44
+ export function open() {
36
45
  dialog?.showModal();
37
46
  }
38
47
  function close() {
@@ -40,9 +49,11 @@ each linking to its edit page, so the author repoints or removes those links fir
40
49
  }
41
50
  </script>
42
51
 
43
- <button type="button" class="btn btn-sm btn-ghost text-error" aria-haspopup="dialog" onclick={open}>
44
- Delete
45
- </button>
52
+ {#if trigger}
53
+ <button type="button" class="btn btn-sm btn-ghost text-error" aria-haspopup="dialog" onclick={open}>
54
+ Delete
55
+ </button>
56
+ {/if}
46
57
 
47
58
  <dialog class="modal" aria-labelledby="cairn-delete-dialog-title" bind:this={dialog}>
48
59
  <div class="modal-box">
@@ -67,8 +78,8 @@ each linking to its edit page, so the author repoints or removes those links fir
67
78
  <button type="button" class="btn btn-sm" onclick={close}>Close</button>
68
79
  </div>
69
80
  {:else}
70
- <p class="mb-3 text-sm">This cannot be undone.</p>
71
- <form method="POST" action="?/delete" class="flex justify-end gap-2">
81
+ <p class="mb-3 text-sm">This cannot be undone.{#if pending} Unpublished edits to this entry are discarded too.{/if}</p>
82
+ <form method="POST" action="?/delete" class="flex justify-end gap-2" onsubmit={() => onsubmitting?.()}>
72
83
  <CsrfField />
73
84
  <input type="hidden" name="concept" value={conceptId} />
74
85
  <input type="hidden" name="id" value={id} />
@@ -8,6 +8,14 @@ interface Props {
8
8
  label: string;
9
9
  /** The entries that link to this one; non-empty blocks the delete. */
10
10
  inboundLinks: InboundLink[];
11
+ /** True when the entry has unpublished edits, which the delete discards along with it. */
12
+ pending?: boolean;
13
+ /** Render the built-in Delete trigger. False mounts only the dialog, for a host that supplies
14
+ * its own trigger and opens the dialog through the exported open(). */
15
+ trigger?: boolean;
16
+ /** Called when the delete confirm submits, before the document navigates. The edit page uses
17
+ * it to stand down its leave guard while the POST is in flight. */
18
+ onsubmitting?: () => void;
11
19
  }
12
20
  /**
13
21
  * The Delete control and its modal. With no inbound links it is a plain confirm that posts to the
@@ -15,6 +23,8 @@ interface Props {
15
23
  * each linking to its edit page, so the author repoints or removes those links first. Built on a native
16
24
  * <dialog>, following the LinkPicker a11y conventions.
17
25
  */
18
- declare const DeleteDialog: import("svelte").Component<Props, {}, "">;
26
+ declare const DeleteDialog: import("svelte").Component<Props, {
27
+ open: () => void;
28
+ }, "">;
19
29
  type DeleteDialog = ReturnType<typeof DeleteDialog>;
20
30
  export default DeleteDialog;