@glw907/cairn-cms 0.40.0 → 0.50.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 (156) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -3
  3. package/dist/ambient.d.ts +9 -0
  4. package/dist/ambient.js +1 -0
  5. package/dist/components/AdminLayout.svelte +6 -8
  6. package/dist/components/CairnAdmin.svelte +67 -0
  7. package/dist/components/CairnAdmin.svelte.d.ts +35 -0
  8. package/dist/components/ConceptList.svelte +18 -10
  9. package/dist/components/ConceptList.svelte.d.ts +4 -8
  10. package/dist/components/ConfirmPage.svelte +1 -1
  11. package/dist/components/EditPage.svelte +47 -19
  12. package/dist/components/EditPage.svelte.d.ts +4 -9
  13. package/dist/components/EditorToolbar.svelte +4 -0
  14. package/dist/components/LoginPage.svelte +2 -2
  15. package/dist/components/LoginPage.svelte.d.ts +1 -1
  16. package/dist/components/ManageEditors.svelte +4 -3
  17. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  18. package/dist/components/index.d.ts +1 -0
  19. package/dist/components/index.js +1 -0
  20. package/dist/components/link-completion.js +10 -3
  21. package/dist/components/markdown-format.d.ts +0 -8
  22. package/dist/components/markdown-format.js +0 -28
  23. package/dist/content/links.d.ts +8 -0
  24. package/dist/content/links.js +28 -0
  25. package/dist/content/types.d.ts +2 -2
  26. package/dist/delivery/data.d.ts +3 -5
  27. package/dist/delivery/data.js +2 -3
  28. package/dist/delivery/feeds.js +1 -7
  29. package/dist/delivery/index.d.ts +2 -2
  30. package/dist/delivery/index.js +1 -1
  31. package/dist/delivery/manifest.d.ts +0 -5
  32. package/dist/delivery/manifest.js +5 -16
  33. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  34. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  35. package/dist/delivery/site-indexes.d.ts +3 -3
  36. package/dist/delivery/site-indexes.js +3 -3
  37. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  38. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  39. package/dist/delivery/sitemap.js +1 -3
  40. package/dist/delivery/xml.d.ts +2 -0
  41. package/dist/delivery/xml.js +11 -0
  42. package/dist/diagnostics/conditions.d.ts +8 -1
  43. package/dist/diagnostics/conditions.js +68 -1
  44. package/dist/doctor/bin.d.ts +2 -0
  45. package/dist/doctor/bin.js +44 -0
  46. package/dist/doctor/check-send.d.ts +3 -0
  47. package/dist/doctor/check-send.js +43 -0
  48. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  49. package/dist/doctor/checks-cloudflare.js +200 -0
  50. package/dist/doctor/checks-github.d.ts +2 -0
  51. package/dist/doctor/checks-github.js +57 -0
  52. package/dist/doctor/checks-local.d.ts +5 -0
  53. package/dist/doctor/checks-local.js +112 -0
  54. package/dist/doctor/cloudflare-api.d.ts +7 -0
  55. package/dist/doctor/cloudflare-api.js +24 -0
  56. package/dist/doctor/index.d.ts +23 -0
  57. package/dist/doctor/index.js +68 -0
  58. package/dist/doctor/report.d.ts +5 -0
  59. package/dist/doctor/report.js +21 -0
  60. package/dist/doctor/run.d.ts +8 -0
  61. package/dist/doctor/run.js +20 -0
  62. package/dist/doctor/types.d.ts +41 -0
  63. package/dist/doctor/types.js +10 -0
  64. package/dist/doctor/wrangler-config.d.ts +12 -0
  65. package/dist/doctor/wrangler-config.js +125 -0
  66. package/dist/email.js +4 -11
  67. package/dist/env.d.ts +1 -1
  68. package/dist/env.js +3 -2
  69. package/dist/escape.d.ts +2 -0
  70. package/dist/escape.js +11 -0
  71. package/dist/github/credentials.d.ts +2 -1
  72. package/dist/github/credentials.js +10 -2
  73. package/dist/github/signing.d.ts +3 -1
  74. package/dist/github/signing.js +13 -5
  75. package/dist/github/types.d.ts +2 -0
  76. package/dist/github/types.js +4 -0
  77. package/dist/log/events.d.ts +1 -1
  78. package/dist/nav/site-config.d.ts +2 -0
  79. package/dist/nav/site-config.js +2 -0
  80. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  81. package/dist/sveltekit/admin-dispatch.js +62 -0
  82. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  83. package/dist/sveltekit/cairn-admin.js +126 -0
  84. package/dist/sveltekit/condition-response.d.ts +1 -0
  85. package/dist/sveltekit/condition-response.js +25 -0
  86. package/dist/sveltekit/content-routes.d.ts +34 -14
  87. package/dist/sveltekit/content-routes.js +78 -44
  88. package/dist/sveltekit/guard.js +15 -3
  89. package/dist/sveltekit/https-required-page.js +2 -1
  90. package/dist/sveltekit/index.d.ts +3 -1
  91. package/dist/sveltekit/index.js +2 -0
  92. package/dist/sveltekit/nav-routes.d.ts +3 -1
  93. package/dist/sveltekit/nav-routes.js +19 -10
  94. package/dist/sveltekit/static-admin-page.d.ts +0 -2
  95. package/dist/sveltekit/static-admin-page.js +1 -8
  96. package/dist/sveltekit/types.d.ts +18 -11
  97. package/package.json +10 -4
  98. package/src/lib/ambient.ts +19 -0
  99. package/src/lib/components/AdminLayout.svelte +6 -8
  100. package/src/lib/components/CairnAdmin.svelte +67 -0
  101. package/src/lib/components/ConceptList.svelte +18 -10
  102. package/src/lib/components/ConfirmPage.svelte +1 -1
  103. package/src/lib/components/EditPage.svelte +47 -19
  104. package/src/lib/components/EditorToolbar.svelte +4 -0
  105. package/src/lib/components/LoginPage.svelte +2 -2
  106. package/src/lib/components/ManageEditors.svelte +4 -3
  107. package/src/lib/components/index.ts +1 -0
  108. package/src/lib/components/link-completion.ts +10 -3
  109. package/src/lib/components/markdown-format.ts +0 -27
  110. package/src/lib/content/links.ts +28 -0
  111. package/src/lib/content/types.ts +2 -2
  112. package/src/lib/delivery/data.ts +3 -5
  113. package/src/lib/delivery/feeds.ts +1 -8
  114. package/src/lib/delivery/index.ts +2 -2
  115. package/src/lib/delivery/manifest.ts +5 -18
  116. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  117. package/src/lib/delivery/site-indexes.ts +6 -6
  118. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  119. package/src/lib/delivery/sitemap.ts +1 -4
  120. package/src/lib/delivery/xml.ts +12 -0
  121. package/src/lib/diagnostics/conditions.ts +75 -2
  122. package/src/lib/doctor/bin.ts +45 -0
  123. package/src/lib/doctor/check-send.ts +43 -0
  124. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  125. package/src/lib/doctor/checks-github.ts +63 -0
  126. package/src/lib/doctor/checks-local.ts +119 -0
  127. package/src/lib/doctor/cloudflare-api.ts +33 -0
  128. package/src/lib/doctor/index.ts +93 -0
  129. package/src/lib/doctor/report.ts +30 -0
  130. package/src/lib/doctor/run.ts +23 -0
  131. package/src/lib/doctor/types.ts +52 -0
  132. package/src/lib/doctor/wrangler-config.ts +142 -0
  133. package/src/lib/email.ts +4 -11
  134. package/src/lib/env.ts +3 -2
  135. package/src/lib/escape.ts +12 -0
  136. package/src/lib/github/credentials.ts +6 -2
  137. package/src/lib/github/signing.ts +13 -6
  138. package/src/lib/github/types.ts +5 -0
  139. package/src/lib/log/events.ts +2 -0
  140. package/src/lib/nav/site-config.ts +3 -0
  141. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  142. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  143. package/src/lib/sveltekit/condition-response.ts +27 -1
  144. package/src/lib/sveltekit/content-routes.ts +121 -55
  145. package/src/lib/sveltekit/guard.ts +16 -3
  146. package/src/lib/sveltekit/https-required-page.ts +2 -1
  147. package/src/lib/sveltekit/index.ts +6 -0
  148. package/src/lib/sveltekit/nav-routes.ts +21 -11
  149. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  150. package/src/lib/sveltekit/types.ts +16 -7
  151. package/dist/delivery/paginate.d.ts +0 -12
  152. package/dist/delivery/paginate.js +0 -20
  153. package/dist/render/index.d.ts +0 -5
  154. package/dist/render/index.js +0 -8
  155. package/src/lib/delivery/paginate.ts +0 -32
  156. package/src/lib/render/index.ts +0 -8
package/CHANGELOG.md CHANGED
@@ -2,6 +2,82 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.50.0
6
+
7
+ The admin now mounts as one catch-all route. A new `createCairnAdmin(runtime, deps)` facade
8
+ serves every admin view through a single `load` and a single `actions` record, the new
9
+ `CairnAdmin` component switches the views on the discriminated `AdminData`, and `parseAdminPath`
10
+ is the one path authority behind both. A site's whole `/admin` surface is now three files (the
11
+ `$lib/cairn.server.ts` composer plus the `/admin/[...path]` route pair) instead of a per-route
12
+ tree of shims whose action names coupled to engine components by bare string. The admin URLs are
13
+ unchanged. The per-surface factories (`createContentRoutes` and friends) stay public as the
14
+ advanced seam.
15
+
16
+ Consumers must: delete the admin route tree and replace it with the two-file mount plus the
17
+ composer; the exact files are in
18
+ [the canonical admin mount](docs/reference/admin-routes.md) and the migration is the `0.50.0`
19
+ section of [the upgrade guide](docs/guides/upgrade-cairn.md). The engine's auth and shell forms
20
+ now post named actions (`?/request`, `?/confirm`, `?/logout`, `?/publishAll`), so a site that
21
+ mounts `LoginPage`, `ConfirmPage`, or `AdminLayout` directly must register those names, and the
22
+ `/admin/auth/logout` server route leaves the contract.
23
+
24
+ Consumers must: rename `createSiteIndex` to `createSiteResolver` and `SiteIndex` to
25
+ `SiteResolver` where imported from `/delivery/data`; the `paginate` helper is deleted.
26
+
27
+ Consumers must: read `form.error` where they read `form.renameError`. Every action failure now
28
+ carries `error: string` as its one-line summary; the structured extras (`brokenLinks`,
29
+ `inboundLinks`) keep their keys beside it.
30
+
31
+ Consumers must: replace the hand-written `App.Locals` block in `src/app.d.ts` with
32
+ `import '@glw907/cairn-cms/ambient';`, the new type-only subpath that ships the
33
+ `App.Locals.editor` augmentation.
34
+
35
+ The diagnostics registry reaches its remaining runtime sites. A missing `AUTH_DB` on a gated
36
+ admin request renders a branded condition page instead of a silent login redirect, and a missing
37
+ email binding, missing GitHub App credentials, and an invalid site config now carry their
38
+ registered condition ids through the error chain and the logs.
39
+ `deps.mintToken` widens to accept a plain string return. The concept list reads published rows
40
+ from the committed manifest in one call, falling back to the per-file crawl only on a repo with
41
+ no manifest yet. Internal layering rides along (one home each for the link rewriter, the escape
42
+ helpers, and the conflict check) with no consumer surface change.
43
+
44
+ This release publishes together with `0.41.0`, so a site crossing from `0.40.0` takes both
45
+ windows in one upgrade.
46
+
47
+ ## 0.41.0
48
+
49
+ `cairn-doctor` ships as a second bin: a setup preflight that runs nine checks over the local config
50
+ files (the wrangler bindings, observability, the CSRF handoff, the site config), the Cloudflare
51
+ account (the onboarded sending domain, Always Use HTTPS, HSTS, the D1 auth store with its schema
52
+ and an owner row), and the GitHub App's full reachability chain. Every check reports into one
53
+ plain-text report, a failure prints its condition's why and remediation from the diagnostics
54
+ registry, and the exit code is 1 on any failure, so the command slots into a deploy script as a
55
+ gate. A missing credential makes the affected checks skip rather than fail, and
56
+ `--send-test <address>` opts into one real email through the Email Sending API. The new
57
+ [Cloudflare readiness guide](docs/guides/cloudflare-readiness.md) walks the same conditions
58
+ manually, a `check:readiness` gate pins that guide to the condition registry, and
59
+ [the doctor reference](docs/reference/doctor.md) covers the flags, the checks, and the CI wiring.
60
+
61
+ The admin layout's GitHub degrade gains a signal. When the pending-entries read fails, the layout
62
+ logs a warn-level `github.unreachable` record and the topbar's Publish site button hides instead
63
+ of showing a count it cannot know.
64
+
65
+ Consumers may: run `npx cairn-doctor --from <address> --repo <owner/name>` as a pre-launch gate,
66
+ work through the readiness guide when standing up a fresh account, and filter Workers Logs on
67
+ `github.unreachable` when the publish button goes missing.
68
+
69
+ A debt batch rides along. The editor's link autocomplete no longer
70
+ pulls CodeMirror into the server bundle, the edit page's load reads its GitHub probes in parallel,
71
+ concurrent cold-start token mints coalesce into one, publish-all pluralizes its commit message and
72
+ an empty publish-all explains itself instead of redirecting silently, the unsaved-changes warning
73
+ tracks client-side navigation and no longer double-fires on a full page unload, the toolbar's
74
+ keyboard tab stop holds across Preview round trips, the word count ignores markdown and directive
75
+ syntax, and the list's publish flash announces reliably to screen readers.
76
+
77
+ Consumers must: be on `@sveltejs/kit` 2.12 or later before taking this release. The edit page now
78
+ reads `$app/state`, which shipped in kit 2.12.0, and the peer range says so (`^2.12`); a site on
79
+ an older kit must upgrade kit first.
80
+
5
81
  ## 0.40.0
6
82
 
7
83
  The edit page is redesigned around the manuscript. A sticky translucent header carries the
package/README.md CHANGED
@@ -45,7 +45,7 @@ doesn't, cairn is the wrong tool and will not try to meet you halfway.
45
45
  tutorial, takes you from an empty directory to a deployed site with a working `/admin`.
46
46
  2. **[`examples/showcase`](./examples/showcase)** is a complete consumer site wired to the
47
47
  engine, and the worked reference for every shape in the docs. When a guide says "mount the
48
- route shims," the showcase shows the mounted result.
48
+ admin," the showcase shows the mounted result.
49
49
  3. **[The docs](./docs/README.md)** are organized in four arms: the tutorial, task guides,
50
50
  one reference page per export, and explanation pages for the architecture and design
51
51
  rules.
@@ -65,8 +65,8 @@ formal contribution process yet, so this is not an open call for pull requests.
65
65
  npm install @glw907/cairn-cms
66
66
  ```
67
67
 
68
- Peer dependencies: `svelte@^5` and `@sveltejs/kit@^2`. A consumer site implements a
69
- `CairnAdapter` and mounts thin `/admin` route shims around the package subpaths:
68
+ Peer dependencies: `svelte@^5` and `@sveltejs/kit@^2.12`. A consumer site implements a
69
+ `CairnAdapter` and mounts the whole `/admin` with one catch-all route over the package subpaths:
70
70
 
71
71
  - `@glw907/cairn-cms`: the core engine and adapter contract.
72
72
  - `@glw907/cairn-cms/sveltekit`: the server load and action logic.
@@ -0,0 +1,9 @@
1
+ import type { Editor } from './auth/types.js';
2
+ declare global {
3
+ namespace App {
4
+ interface Locals {
5
+ editor?: Editor | null;
6
+ }
7
+ }
8
+ }
9
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -279,7 +279,7 @@ identical on every host regardless of the site's own theme.
279
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>
280
280
  </button>
281
281
  </div>
282
- {#if pendingCount > 0 && data.concepts.length > 0}
282
+ {#if pendingCount > 0}
283
283
  <div class="flex-none">
284
284
  <button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => publishAllDialog?.showModal()}>
285
285
  Publish site ({pendingCount})
@@ -349,9 +349,7 @@ identical on every host regardless of the site's own theme.
349
349
  <form method="dialog" class="modal-backdrop"><button tabindex="-1" aria-label="Close">close</button></form>
350
350
  </dialog>
351
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}
352
+ {#if pendingCount > 0}
355
353
  <dialog bind:this={publishAllDialog} class="modal" aria-labelledby="cairn-publish-all-title">
356
354
  <div class="modal-box">
357
355
  <div class="mb-3 flex items-center justify-between">
@@ -367,9 +365,9 @@ identical on every host regardless of the site's own theme.
367
365
  {/each}
368
366
  </ul>
369
367
  {/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">
368
+ <!-- The publishAll named action is valid on every authed admin view, so the confirm
369
+ posts to the current page and the topbar works from anywhere. -->
370
+ <form method="POST" action="?/publishAll" class="mt-4 flex justify-end gap-2">
373
371
  <CsrfField token={data.csrf} />
374
372
  <button type="button" class="btn btn-sm" onclick={() => publishAllDialog?.close()}>Cancel</button>
375
373
  <button type="submit" class="btn btn-sm btn-primary">Publish site</button>
@@ -455,7 +453,7 @@ identical on every host regardless of the site's own theme.
455
453
  <div class="text-xs capitalize text-[var(--color-subtle)]">{data.user.role}</div>
456
454
  </div>
457
455
  </div>
458
- <form method="POST" action="/admin/auth/logout" class="mt-4">
456
+ <form method="POST" action="?/logout" class="mt-4">
459
457
  <CsrfField token={data.csrf} />
460
458
  <button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">
461
459
  <LogOutIcon class="h-4 w-4" /> Sign out
@@ -0,0 +1,67 @@
1
+ <!--
2
+ @component
3
+ The single-mount admin page. A site's catch-all `/admin/[...path]` route renders this one
4
+ component for every admin view, feeding it the discriminated `AdminData` from `createCairnAdmin`'s
5
+ load. It is a pure switcher on `data.view`: the public auth pages mount bare, and the authed views
6
+ mount inside `AdminLayout`. No styling or wrapper elements of its own.
7
+ -->
8
+ <script lang="ts">
9
+ import AdminLayout from './AdminLayout.svelte';
10
+ import LoginPage from './LoginPage.svelte';
11
+ import ConfirmPage from './ConfirmPage.svelte';
12
+ import ConceptList from './ConceptList.svelte';
13
+ import EditPage from './EditPage.svelte';
14
+ import ManageEditors from './ManageEditors.svelte';
15
+ import NavTree from './NavTree.svelte';
16
+ import type { AdminData } from '../sveltekit/cairn-admin.js';
17
+ import type { ContentFormFailure } from '../sveltekit/content-routes.js';
18
+ import type { ComponentRegistry } from '../render/registry.js';
19
+ import type { IconSet } from '../render/glyph.js';
20
+ import type { LinkResolve } from '../content/links.js';
21
+
22
+ interface Props {
23
+ /** The discriminated view data from `createCairnAdmin`'s load. */
24
+ data: AdminData;
25
+ /** The last action's result, forwarded to whichever view rendered: the shared content-action
26
+ * failure family (every failure carries `error`), merged with the auth and editors results,
27
+ * so the route's one `form` export covers every view. */
28
+ form?:
29
+ | (ContentFormFailure & {
30
+ sent?: boolean;
31
+ status?: 'sent' | 'send_error' | 'throttled';
32
+ ok?: boolean;
33
+ })
34
+ | null;
35
+ /** The site's design-accurate render pipeline, for the edit view's preview pane. */
36
+ render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
37
+ /** The site's component registry, for the edit view's insert palette. */
38
+ registry?: ComponentRegistry;
39
+ /** The site's icon set, for the edit view's guided form fields. */
40
+ icons?: IconSet;
41
+ }
42
+
43
+ let { data, form = null, render, registry, icons }: Props = $props();
44
+ </script>
45
+
46
+ {#if data.view === 'login'}
47
+ <LoginPage data={data.page} {form} />
48
+ {:else if data.view === 'confirm'}
49
+ <ConfirmPage data={data.page} />
50
+ {:else}
51
+ <AdminLayout data={data.layout}>
52
+ {#if data.view === 'list'}
53
+ <!-- The single mount reuses this component across /admin/posts -> /admin/pages, so the
54
+ concept id keys the list: crossing concepts remounts it and drops the old query,
55
+ sort, page, and dialog state. -->
56
+ {#key data.page.conceptId}
57
+ <ConceptList data={data.page} {form} />
58
+ {/key}
59
+ {:else if data.view === 'edit'}
60
+ <EditPage data={{ ...data.page, siteName: data.layout.siteName }} {render} {registry} {icons} {form} />
61
+ {:else if data.view === 'editors'}
62
+ <ManageEditors data={data.page} {form} />
63
+ {:else if data.view === 'nav'}
64
+ <NavTree data={data.page} />
65
+ {/if}
66
+ </AdminLayout>
67
+ {/if}
@@ -0,0 +1,35 @@
1
+ import type { AdminData } from '../sveltekit/cairn-admin.js';
2
+ import type { ContentFormFailure } from '../sveltekit/content-routes.js';
3
+ import type { ComponentRegistry } from '../render/registry.js';
4
+ import type { IconSet } from '../render/glyph.js';
5
+ import type { LinkResolve } from '../content/links.js';
6
+ interface Props {
7
+ /** The discriminated view data from `createCairnAdmin`'s load. */
8
+ data: AdminData;
9
+ /** The last action's result, forwarded to whichever view rendered: the shared content-action
10
+ * failure family (every failure carries `error`), merged with the auth and editors results,
11
+ * so the route's one `form` export covers every view. */
12
+ form?: (ContentFormFailure & {
13
+ sent?: boolean;
14
+ status?: 'sent' | 'send_error' | 'throttled';
15
+ ok?: boolean;
16
+ }) | null;
17
+ /** The site's design-accurate render pipeline, for the edit view's preview pane. */
18
+ render?: (md: string, opts?: {
19
+ stagger?: boolean;
20
+ resolve?: LinkResolve;
21
+ }) => string | Promise<string>;
22
+ /** The site's component registry, for the edit view's insert palette. */
23
+ registry?: ComponentRegistry;
24
+ /** The site's icon set, for the edit view's guided form fields. */
25
+ icons?: IconSet;
26
+ }
27
+ /**
28
+ * The single-mount admin page. A site's catch-all `/admin/[...path]` route renders this one
29
+ * component for every admin view, feeding it the discriminated `AdminData` from `createCairnAdmin`'s
30
+ * load. It is a pure switcher on `data.view`: the public auth pages mount bare, and the authed views
31
+ * mount inside `AdminLayout`. No styling or wrapper elements of its own.
32
+ */
33
+ declare const CairnAdmin: import("svelte").Component<Props, {}, "">;
34
+ type CairnAdmin = ReturnType<typeof CairnAdmin>;
35
+ export default CairnAdmin;
@@ -7,8 +7,7 @@ content sizes. The header New button opens a dialog holding the create form.
7
7
  -->
8
8
  <script lang="ts">
9
9
  import { slugify } from '../content/ids.js';
10
- import type { EntrySummary, ListData } from '../sveltekit/content-routes.js';
11
- import type { InboundLink } from '../content/manifest.js';
10
+ import type { DeleteRefusal, EntrySummary, ListData } from '../sveltekit/content-routes.js';
12
11
  import CsrfField from './CsrfField.svelte';
13
12
  import DeleteDialog from './DeleteDialog.svelte';
14
13
  import CairnLogo from './CairnLogo.svelte';
@@ -17,10 +16,10 @@ content sizes. The header New button opens a dialog holding the create form.
17
16
  interface Props {
18
17
  /** The list load's data: the concept, its entries, and any inline or form errors. */
19
18
  data: ListData;
20
- /** The `?/delete` action result. A blocked delete returns the refused entry id and the inbound
21
- * links that link to it (the flat `fail(409, { inboundLinks, id })` shape), so the list names
19
+ /** The `?/delete` action result. A blocked delete returns the `DeleteRefusal` payload (the
20
+ * shared `error` summary, the refused entry id, and its inbound linkers), so the list names
22
21
  * the blockers and refuses (block-until-clean). */
23
- form?: { id?: string; inboundLinks?: InboundLink[] } | null;
22
+ form?: Partial<DeleteRefusal> | null;
24
23
  }
25
24
 
26
25
  let { data, form = null }: Props = $props();
@@ -107,6 +106,14 @@ content sizes. The header New button opens a dialog holding the create form.
107
106
  // flex layout and a hover affordance on top of this.
108
107
  const headerLabel = 'text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
109
108
  const sortButton = `inline-flex items-center gap-1 ${headerLabel} hover:text-base-content`;
109
+
110
+ // The publish-all flash. A racing second admin can publish first, leaving this redirect
111
+ // counting zero; say nothing then.
112
+ const publishedAllMessage = $derived(
113
+ data.publishedAll !== null && data.publishedAll > 0
114
+ ? `Published ${data.publishedAll} ${data.publishedAll === 1 ? 'entry' : 'entries'}.`
115
+ : '',
116
+ );
110
117
  </script>
111
118
 
112
119
  <header class="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
@@ -122,11 +129,12 @@ content sizes. The header New button opens a dialog holding the create form.
122
129
  </div>
123
130
  </header>
124
131
 
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>
132
+ <!-- One persistent live region announces the publish-all flash (the EditPage pattern): a
133
+ {#if}-gated role element inserted fresh is announced inconsistently, so the visible alert
134
+ below keeps its styling without a role and the message is announced once. -->
135
+ <div class="sr-only" aria-live="polite">{publishedAllMessage}</div>
136
+ {#if publishedAllMessage}
137
+ <div class="alert alert-success mb-4 text-sm">{publishedAllMessage}</div>
130
138
  {/if}
131
139
  {#if data.formError}
132
140
  <div role="alert" class="alert alert-error mb-4 text-sm">{data.formError}</div>
@@ -1,15 +1,11 @@
1
- import type { ListData } from '../sveltekit/content-routes.js';
2
- import type { InboundLink } from '../content/manifest.js';
1
+ import type { DeleteRefusal, ListData } from '../sveltekit/content-routes.js';
3
2
  interface Props {
4
3
  /** The list load's data: the concept, its entries, and any inline or form errors. */
5
4
  data: ListData;
6
- /** The `?/delete` action result. A blocked delete returns the refused entry id and the inbound
7
- * links that link to it (the flat `fail(409, { inboundLinks, id })` shape), so the list names
5
+ /** The `?/delete` action result. A blocked delete returns the `DeleteRefusal` payload (the
6
+ * shared `error` summary, the refused entry id, and its inbound linkers), so the list names
8
7
  * the blockers and refuses (block-until-clean). */
9
- form?: {
10
- id?: string;
11
- inboundLinks?: InboundLink[];
12
- } | null;
8
+ form?: Partial<DeleteRefusal> | null;
13
9
  }
14
10
  /**
15
11
  * One concept's list view as a DaisyUI data-table: a search filter, a result count, sortable Title and
@@ -40,7 +40,7 @@ in a hidden field and consumes nothing; only the explicit POST verifies (spec §
40
40
  {:else}
41
41
  <h1 class="text-lg font-semibold">Almost there</h1>
42
42
  <p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Confirm to finish signing in to {data.siteName}.</p>
43
- <form method="POST">
43
+ <form method="POST" action="?/confirm">
44
44
  <input type="hidden" name="token" value={data.token} />
45
45
  <CsrfField token={data.csrf} />
46
46
  <button type="submit" class="btn btn-primary btn-block">Confirm sign-in</button>
@@ -14,6 +14,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
14
14
  <script lang="ts">
15
15
  import { untrack } from 'svelte';
16
16
  import { beforeNavigate } from '$app/navigation';
17
+ import { page } from '$app/state';
17
18
  import CsrfField from './CsrfField.svelte';
18
19
  import MarkdownEditor from './MarkdownEditor.svelte';
19
20
  import EditorToolbar from './EditorToolbar.svelte';
@@ -25,10 +26,10 @@ transient flashes, and the editor card's footer holds the word count and the Mar
25
26
  import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
26
27
  import { cairnLinkCompletionSource } from './link-completion.js';
27
28
  import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
28
- import { directiveLineKind } from './markdown-directives.js';
29
+ import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
29
30
  import type { ComponentRegistry } from '../render/registry.js';
30
31
  import type { IconSet } from '../render/glyph.js';
31
- import type { EditData } from '../sveltekit/content-routes.js';
32
+ import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
32
33
  import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
33
34
  import type { LinkResolve } from '../content/links.js';
34
35
  import { manifestLinkResolver } from '../content/manifest.js';
@@ -42,9 +43,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
42
43
  render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
43
44
  /** The site's icon set, for the guided form's icon fields. */
44
45
  icons?: IconSet;
45
- /** The `?/save` or `?/delete` action result. Carries the save guard's broken links when a save was
46
- * blocked, or the delete guard's inbound linkers when a delete was refused. */
47
- form?: { brokenLinks?: string[]; body?: string; inboundLinks?: import('../content/manifest.js').InboundLink[]; renameError?: string } | null;
46
+ /** The last content action's failure: the save guard's broken links, the delete guard's
47
+ * inbound linkers, or a rename refusal, each carrying the shared `error` summary. */
48
+ form?: ContentFormFailure | null;
48
49
  }
49
50
 
50
51
  let { data, registry, render, icons, form }: Props = $props();
@@ -101,6 +102,14 @@ transient flashes, and the editor card's footer holds the word count and the Mar
101
102
  // navigation passes through because busy flips before it starts, and a non-edit POST's because
102
103
  // leaving does.
103
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
+ }
104
113
  if (dirty && !busy && !leaving && !confirm('You have unsaved changes. Leave anyway?'))
105
114
  navigation.cancel();
106
115
  });
@@ -211,8 +220,12 @@ transient flashes, and the editor card's footer holds the word count and the Mar
211
220
  // not refused. When set, a delete was blocked by a link that appeared since the page loaded.
212
221
  const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
213
222
 
214
- // A rename that hit a collision or an invalid slug returns form.renameError.
215
- const renameError = $derived(form?.renameError ?? '');
223
+ // The shared failure summary, rendered only when no richer banner claims the failure: the save
224
+ // and delete guards get their own banners from brokenLinks and inboundLinks below, so this
225
+ // surfaces the rest (a rename refusal, today).
226
+ const formError = $derived(
227
+ form?.error && !form.brokenLinks?.length && !form.inboundLinks?.length ? form.error : '',
228
+ );
216
229
 
217
230
  // The entry this surface is editing. SvelteKit reuses the page component across a same-route
218
231
  // navigation (the delete-refused and broken-link banners link entry to entry), so the per-entry
@@ -240,14 +253,12 @@ transient flashes, and the editor card's footer holds the word count and the Mar
240
253
  });
241
254
  });
242
255
 
243
- // After a save that links to a draft target, the redirect carries ?drafts=<tokens>. Re-read on
244
- // an entry change too, since a client-side navigation swaps the search string under this effect.
245
- let draftWarning = $state('');
246
- $effect(() => {
247
- void entryKey;
248
- const search = typeof location === 'undefined' ? '' : location.search;
249
- const drafts = new URLSearchParams(search).get('drafts');
250
- draftWarning = drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
256
+ // After a save that links to a draft target, the redirect carries ?drafts=<tokens>. page.url
257
+ // is reactive kit state, so a client-side navigation that swaps the search string re-derives
258
+ // this, and the read is SSR-safe.
259
+ const draftWarning = $derived.by(() => {
260
+ const drafts = page.url.searchParams.get('drafts');
261
+ return drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
251
262
  });
252
263
 
253
264
  // The one transient feedback strip under the sticky header. The redirect flags are mutually
@@ -272,7 +283,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
272
283
  );
273
284
  const assertiveMessage = $derived.by(() => {
274
285
  if (data.error) return data.error;
275
- if (renameError) return renameError;
286
+ if (formError) return formError;
276
287
  if (deleteRefusedLinks.length) {
277
288
  const count = deleteRefusedLinks.length;
278
289
  return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? 'page links' : 'pages link'} to it.`;
@@ -284,12 +295,29 @@ transient flashes, and the editor card's footer holds the word count and the Mar
284
295
  return '';
285
296
  });
286
297
 
298
+ // One line of body text reduced to its prose: inline directives drop wholesale, then the
299
+ // markdown marker characters become spaces. Spacing rather than deleting keeps "[text](url)"
300
+ // as two words instead of mashing the link text into its destination, so a link counts its
301
+ // text plus its URL and the count never undercounts prose.
302
+ function proseOnly(line: string): string {
303
+ let out = '';
304
+ let cursor = 0;
305
+ for (const { from, to } of findInlineDirectives(line)) {
306
+ out += line.slice(cursor, from);
307
+ cursor = to;
308
+ }
309
+ out += line.slice(cursor);
310
+ return out.replace(/[*_~`[\]()#]/g, ' ');
311
+ }
312
+
287
313
  // The editor footer's word count, over the local body so it tracks every keystroke. Directive
288
- // machinery lines and table rows are dropped first, so the count reads as the author's prose.
314
+ // machinery lines and table rows are dropped first and the inline syntax stripped, so the
315
+ // count reads as the author's prose.
289
316
  const countedBody = $derived(
290
317
  body
291
318
  .split('\n')
292
319
  .filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line))
320
+ .map(proseOnly)
293
321
  .join('\n'),
294
322
  );
295
323
  const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
@@ -505,8 +533,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
505
533
  {#if data.error}
506
534
  <div class="alert alert-error mb-4 text-sm">{data.error}</div>
507
535
  {/if}
508
- {#if renameError}
509
- <div class="alert alert-error mb-4 text-sm">{renameError}</div>
536
+ {#if formError}
537
+ <div class="alert alert-error mb-4 text-sm">{formError}</div>
510
538
  {/if}
511
539
  {#if deleteRefusedLinks.length}
512
540
  <div class="alert alert-error mb-4 flex-col items-start text-sm">
@@ -1,6 +1,6 @@
1
1
  import type { ComponentRegistry } from '../render/registry.js';
2
2
  import type { IconSet } from '../render/glyph.js';
3
- import type { EditData } from '../sveltekit/content-routes.js';
3
+ import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
4
4
  import type { LinkResolve } from '../content/links.js';
5
5
  interface Props {
6
6
  /** The edit load's data, plus the site name for the heading. */
@@ -16,14 +16,9 @@ interface Props {
16
16
  }) => string | Promise<string>;
17
17
  /** The site's icon set, for the guided form's icon fields. */
18
18
  icons?: IconSet;
19
- /** The `?/save` or `?/delete` action result. Carries the save guard's broken links when a save was
20
- * blocked, or the delete guard's inbound linkers when a delete was refused. */
21
- form?: {
22
- brokenLinks?: string[];
23
- body?: string;
24
- inboundLinks?: import('../content/manifest.js').InboundLink[];
25
- renameError?: string;
26
- } | null;
19
+ /** The last content action's failure: the save guard's broken links, the delete guard's
20
+ * inbound linkers, or a rename refusal, each carrying the shared `error` summary. */
21
+ form?: ContentFormFailure | null;
27
22
  }
28
23
  /**
29
24
  * The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
@@ -113,6 +113,10 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
113
113
  const items = rovingControls();
114
114
  if (items.length === 0) return;
115
115
  const stop = Math.min(roving, items.length - 1);
116
+ // Write the clamp back so the stored stop never drifts from the displayed one across a
117
+ // Preview round trip. The effect reads roving, so the guarded write re-runs it once and
118
+ // converges (the second pass computes the same stop and writes nothing).
119
+ if (stop !== roving) roving = stop;
116
120
  for (const [i, el] of items.entries()) el.setAttribute('tabindex', i === stop ? '0' : '-1');
117
121
  });
118
122
 
@@ -1,6 +1,6 @@
1
1
  <!--
2
2
  @component
3
- The magic-link sign-in page. A plain form POST to the page's default action (the engine's
3
+ The magic-link sign-in page. A plain form POST to the named `?/request` action (the engine's
4
4
  `requestAction`); no client SDK. The success message is identical whether or not the email is on
5
5
  the allowlist, so the page never leaks membership (spec §7.1).
6
6
  -->
@@ -101,7 +101,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
101
101
  {#if data.error && !form?.status}
102
102
  <div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
103
103
  {/if}
104
- <form method="POST" class="flex flex-col gap-3">
104
+ <form method="POST" action="?/request" class="flex flex-col gap-3">
105
105
  <CsrfField token={data.csrf} />
106
106
  <label class="flex flex-col gap-1">
107
107
  <span class="text-sm font-medium">Email</span>
@@ -14,7 +14,7 @@ interface Props {
14
14
  } | null;
15
15
  }
16
16
  /**
17
- * The magic-link sign-in page. A plain form POST to the page's default action (the engine's
17
+ * The magic-link sign-in page. A plain form POST to the named `?/request` action (the engine's
18
18
  * `requestAction`); no client SDK. The success message is identical whether or not the email is on
19
19
  * the allowlist, so the page never leaks membership (spec §7.1).
20
20
  */
@@ -3,7 +3,8 @@
3
3
  The owner-gated editor management surface: a table of editors with role-flip and remove actions,
4
4
  and an add-editor form. The acting owner's own row disables its destructive controls; the
5
5
  last-owner anti-lockout rule itself is enforced server-side (editors-routes). Actions post to the
6
- named `?/setRole`, `?/remove`, and `?/add` actions.
6
+ named `?/setRole`, `?/removeEditor`, and `?/addEditor` actions, the names the single-mount
7
+ dispatcher defines.
7
8
  -->
8
9
  <script lang="ts">
9
10
  import CsrfField from './CsrfField.svelte';
@@ -53,7 +54,7 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
53
54
  {editor.role === 'owner' ? 'Make editor' : 'Make owner'}
54
55
  </button>
55
56
  </form>
56
- <form method="POST" action="?/remove">
57
+ <form method="POST" action="?/removeEditor">
57
58
  <CsrfField />
58
59
  <input type="hidden" name="email" value={editor.email} />
59
60
  <button type="submit" class="btn btn-ghost btn-xs text-error" disabled={isSelf} aria-label={`Remove ${editor.displayName}`}>
@@ -67,7 +68,7 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
67
68
  </table>
68
69
  </div>
69
70
 
70
- <form method="POST" action="?/add" class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 grid gap-3 p-4 shadow-[var(--cairn-shadow)] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
71
+ <form method="POST" action="?/addEditor" class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 grid gap-3 p-4 shadow-[var(--cairn-shadow)] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
71
72
  <CsrfField />
72
73
  <label class="flex flex-col gap-1">
73
74
  <span class="text-sm font-medium">Name</span>
@@ -15,7 +15,8 @@ interface Props {
15
15
  * The owner-gated editor management surface: a table of editors with role-flip and remove actions,
16
16
  * and an add-editor form. The acting owner's own row disables its destructive controls; the
17
17
  * last-owner anti-lockout rule itself is enforced server-side (editors-routes). Actions post to the
18
- * named `?/setRole`, `?/remove`, and `?/add` actions.
18
+ * named `?/setRole`, `?/removeEditor`, and `?/addEditor` actions, the names the single-mount
19
+ * dispatcher defines.
19
20
  */
20
21
  declare const ManageEditors: import("svelte").Component<Props, {}, "">;
21
22
  type ManageEditors = ReturnType<typeof ManageEditors>;
@@ -1,3 +1,4 @@
1
+ export { default as CairnAdmin } from './CairnAdmin.svelte';
1
2
  export { default as AdminLayout } from './AdminLayout.svelte';
2
3
  export { default as LoginPage } from './LoginPage.svelte';
3
4
  export { default as ConfirmPage } from './ConfirmPage.svelte';
@@ -1,5 +1,6 @@
1
1
  // Admin Svelte components (Plan 05). The Warm Stone theme ships as a CSS side effect imported
2
2
  // by the components that set `data-theme="cairn-admin"`.
3
+ export { default as CairnAdmin } from './CairnAdmin.svelte';
3
4
  export { default as AdminLayout } from './AdminLayout.svelte';
4
5
  export { default as LoginPage } from './LoginPage.svelte';
5
6
  export { default as ConfirmPage } from './ConfirmPage.svelte';