@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
@@ -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">
@@ -107,6 +107,14 @@ content sizes. The header New button opens a dialog holding the create form.
107
107
  // flex layout and a hover affordance on top of this.
108
108
  const headerLabel = 'text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
109
109
  const sortButton = `inline-flex items-center gap-1 ${headerLabel} hover:text-base-content`;
110
+
111
+ // The publish-all flash. A racing second admin can publish first, leaving this redirect
112
+ // counting zero; say nothing then.
113
+ const publishedAllMessage = $derived(
114
+ data.publishedAll !== null && data.publishedAll > 0
115
+ ? `Published ${data.publishedAll} ${data.publishedAll === 1 ? 'entry' : 'entries'}.`
116
+ : '',
117
+ );
110
118
  </script>
111
119
 
112
120
  <header class="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
@@ -122,6 +130,13 @@ content sizes. The header New button opens a dialog holding the create form.
122
130
  </div>
123
131
  </header>
124
132
 
133
+ <!-- One persistent live region announces the publish-all flash (the EditPage pattern): a
134
+ {#if}-gated role element inserted fresh is announced inconsistently, so the visible alert
135
+ below keeps its styling without a role and the message is announced once. -->
136
+ <div class="sr-only" aria-live="polite">{publishedAllMessage}</div>
137
+ {#if publishedAllMessage}
138
+ <div class="alert alert-success mb-4 text-sm">{publishedAllMessage}</div>
139
+ {/if}
125
140
  {#if data.formError}
126
141
  <div role="alert" class="alert alert-error mb-4 text-sm">{data.formError}</div>
127
142
  {/if}
@@ -194,13 +209,17 @@ content sizes. The header New button opens a dialog holding the create form.
194
209
  <td><a class="font-medium hover:text-primary hover:underline" href={`/admin/${data.conceptId}/${entry.id}`}>{entry.title}</a></td>
195
210
  {#if data.dated}<td class="hidden text-sm text-[var(--color-muted)] sm:table-cell">{formatDate(entry.date)}</td>{/if}
196
211
  <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}
212
+ <div class="flex flex-wrap items-center gap-1">
213
+ {#if entry.status === 'new'}<span class="badge badge-info badge-sm font-medium">New</span>
214
+ {:else if entry.status === 'edited'}<span class="badge badge-warning badge-sm font-medium">Edited</span>
215
+ {:else}<span class="badge badge-ghost badge-sm font-medium">Published</span>{/if}
216
+ {#if entry.draft}<span class="badge badge-neutral badge-sm font-medium">Hidden</span>{/if}
217
+ </div>
199
218
  </td>
200
219
  <td class="text-right">
201
220
  {#if deleteRefused?.id === entry.id}
202
221
  <!-- 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} />
222
+ <DeleteDialog conceptId={data.conceptId} id={entry.id} label={data.label} inboundLinks={deleteRefused.inboundLinks} pending={entry.status !== 'published'} />
204
223
  {:else}
205
224
  <form method="POST" action="?/delete">
206
225
  <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} />