@glw907/cairn-cms 0.41.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 (113) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +2 -2
  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 +4 -5
  9. package/dist/components/ConceptList.svelte.d.ts +4 -8
  10. package/dist/components/ConfirmPage.svelte +1 -1
  11. package/dist/components/EditPage.svelte +13 -9
  12. package/dist/components/EditPage.svelte.d.ts +4 -9
  13. package/dist/components/LoginPage.svelte +2 -2
  14. package/dist/components/LoginPage.svelte.d.ts +1 -1
  15. package/dist/components/ManageEditors.svelte +4 -3
  16. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  17. package/dist/components/index.d.ts +1 -0
  18. package/dist/components/index.js +1 -0
  19. package/dist/components/markdown-format.d.ts +0 -8
  20. package/dist/components/markdown-format.js +0 -28
  21. package/dist/content/links.d.ts +8 -0
  22. package/dist/content/links.js +28 -0
  23. package/dist/content/types.d.ts +2 -2
  24. package/dist/delivery/data.d.ts +3 -5
  25. package/dist/delivery/data.js +2 -3
  26. package/dist/delivery/feeds.js +1 -7
  27. package/dist/delivery/index.d.ts +2 -2
  28. package/dist/delivery/index.js +1 -1
  29. package/dist/delivery/manifest.d.ts +0 -5
  30. package/dist/delivery/manifest.js +5 -16
  31. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  32. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  33. package/dist/delivery/site-indexes.d.ts +3 -3
  34. package/dist/delivery/site-indexes.js +3 -3
  35. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  36. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  37. package/dist/delivery/sitemap.js +1 -3
  38. package/dist/delivery/xml.d.ts +2 -0
  39. package/dist/delivery/xml.js +11 -0
  40. package/dist/email.js +4 -11
  41. package/dist/env.d.ts +1 -1
  42. package/dist/env.js +3 -2
  43. package/dist/escape.d.ts +2 -0
  44. package/dist/escape.js +11 -0
  45. package/dist/github/credentials.d.ts +2 -1
  46. package/dist/github/credentials.js +10 -2
  47. package/dist/github/types.d.ts +2 -0
  48. package/dist/github/types.js +4 -0
  49. package/dist/log/events.d.ts +1 -1
  50. package/dist/nav/site-config.d.ts +2 -0
  51. package/dist/nav/site-config.js +2 -0
  52. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  53. package/dist/sveltekit/admin-dispatch.js +62 -0
  54. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  55. package/dist/sveltekit/cairn-admin.js +126 -0
  56. package/dist/sveltekit/condition-response.d.ts +1 -0
  57. package/dist/sveltekit/condition-response.js +25 -0
  58. package/dist/sveltekit/content-routes.d.ts +34 -14
  59. package/dist/sveltekit/content-routes.js +59 -33
  60. package/dist/sveltekit/guard.js +15 -3
  61. package/dist/sveltekit/https-required-page.js +2 -1
  62. package/dist/sveltekit/index.d.ts +3 -1
  63. package/dist/sveltekit/index.js +2 -0
  64. package/dist/sveltekit/nav-routes.d.ts +3 -1
  65. package/dist/sveltekit/nav-routes.js +19 -10
  66. package/dist/sveltekit/static-admin-page.d.ts +0 -2
  67. package/dist/sveltekit/static-admin-page.js +1 -8
  68. package/dist/sveltekit/types.d.ts +18 -11
  69. package/package.json +5 -1
  70. package/src/lib/ambient.ts +19 -0
  71. package/src/lib/components/AdminLayout.svelte +6 -8
  72. package/src/lib/components/CairnAdmin.svelte +67 -0
  73. package/src/lib/components/ConceptList.svelte +4 -5
  74. package/src/lib/components/ConfirmPage.svelte +1 -1
  75. package/src/lib/components/EditPage.svelte +13 -9
  76. package/src/lib/components/LoginPage.svelte +2 -2
  77. package/src/lib/components/ManageEditors.svelte +4 -3
  78. package/src/lib/components/index.ts +1 -0
  79. package/src/lib/components/markdown-format.ts +0 -27
  80. package/src/lib/content/links.ts +28 -0
  81. package/src/lib/content/types.ts +2 -2
  82. package/src/lib/delivery/data.ts +3 -5
  83. package/src/lib/delivery/feeds.ts +1 -8
  84. package/src/lib/delivery/index.ts +2 -2
  85. package/src/lib/delivery/manifest.ts +5 -18
  86. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  87. package/src/lib/delivery/site-indexes.ts +6 -6
  88. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  89. package/src/lib/delivery/sitemap.ts +1 -4
  90. package/src/lib/delivery/xml.ts +12 -0
  91. package/src/lib/email.ts +4 -11
  92. package/src/lib/env.ts +3 -2
  93. package/src/lib/escape.ts +12 -0
  94. package/src/lib/github/credentials.ts +6 -2
  95. package/src/lib/github/types.ts +5 -0
  96. package/src/lib/log/events.ts +1 -0
  97. package/src/lib/nav/site-config.ts +3 -0
  98. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  99. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  100. package/src/lib/sveltekit/condition-response.ts +27 -1
  101. package/src/lib/sveltekit/content-routes.ts +102 -45
  102. package/src/lib/sveltekit/guard.ts +16 -3
  103. package/src/lib/sveltekit/https-required-page.ts +2 -1
  104. package/src/lib/sveltekit/index.ts +6 -0
  105. package/src/lib/sveltekit/nav-routes.ts +21 -11
  106. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  107. package/src/lib/sveltekit/types.ts +16 -7
  108. package/dist/delivery/paginate.d.ts +0 -12
  109. package/dist/delivery/paginate.js +0 -20
  110. package/dist/render/index.d.ts +0 -5
  111. package/dist/render/index.js +0 -8
  112. package/src/lib/delivery/paginate.ts +0 -32
  113. package/src/lib/render/index.ts +0 -8
package/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
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
+
5
47
  ## 0.41.0
6
48
 
7
49
  `cairn-doctor` ships as a second bin: a setup preflight that runs nine checks over the local config
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.
@@ -66,7 +66,7 @@ npm install @glw907/cairn-cms
66
66
  ```
67
67
 
68
68
  Peer dependencies: `svelte@^5` and `@sveltejs/kit@^2.12`. A consumer site implements a
69
- `CairnAdapter` and mounts thin `/admin` route shims around the package subpaths:
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();
@@ -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>
@@ -29,7 +29,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
29
29
  import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
30
30
  import type { ComponentRegistry } from '../render/registry.js';
31
31
  import type { IconSet } from '../render/glyph.js';
32
- import type { EditData } from '../sveltekit/content-routes.js';
32
+ import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
33
33
  import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
34
34
  import type { LinkResolve } from '../content/links.js';
35
35
  import { manifestLinkResolver } from '../content/manifest.js';
@@ -43,9 +43,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
43
43
  render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
44
44
  /** The site's icon set, for the guided form's icon fields. */
45
45
  icons?: IconSet;
46
- /** The `?/save` or `?/delete` action result. Carries the save guard's broken links when a save was
47
- * blocked, or the delete guard's inbound linkers when a delete was refused. */
48
- 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;
49
49
  }
50
50
 
51
51
  let { data, registry, render, icons, form }: Props = $props();
@@ -220,8 +220,12 @@ transient flashes, and the editor card's footer holds the word count and the Mar
220
220
  // not refused. When set, a delete was blocked by a link that appeared since the page loaded.
221
221
  const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
222
222
 
223
- // A rename that hit a collision or an invalid slug returns form.renameError.
224
- 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
+ );
225
229
 
226
230
  // The entry this surface is editing. SvelteKit reuses the page component across a same-route
227
231
  // navigation (the delete-refused and broken-link banners link entry to entry), so the per-entry
@@ -279,7 +283,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
279
283
  );
280
284
  const assertiveMessage = $derived.by(() => {
281
285
  if (data.error) return data.error;
282
- if (renameError) return renameError;
286
+ if (formError) return formError;
283
287
  if (deleteRefusedLinks.length) {
284
288
  const count = deleteRefusedLinks.length;
285
289
  return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? 'page links' : 'pages link'} to it.`;
@@ -529,8 +533,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
529
533
  {#if data.error}
530
534
  <div class="alert alert-error mb-4 text-sm">{data.error}</div>
531
535
  {/if}
532
- {#if renameError}
533
- <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>
534
538
  {/if}
535
539
  {#if deleteRefusedLinks.length}
536
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
@@ -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';
@@ -22,11 +22,3 @@ export declare function insertInlineLink(doc: string, from: number, to: number,
22
22
  * is left in place.
23
23
  */
24
24
  export declare function unwrapCairnLink(doc: string, href: string): string;
25
- /**
26
- * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
27
- * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
28
- * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
29
- * span is not a link node and is never touched. Each matching node's source span is rewritten from
30
- * last to first, replacing only the `](oldHref` run so the label and title stay exact.
31
- */
32
- export declare function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string;
@@ -157,31 +157,3 @@ export function unwrapCairnLink(doc, href) {
157
157
  }
158
158
  return out;
159
159
  }
160
- /**
161
- * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
162
- * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
163
- * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
164
- * span is not a link node and is never touched. Each matching node's source span is rewritten from
165
- * last to first, replacing only the `](oldHref` run so the label and title stay exact.
166
- */
167
- export function rewriteCairnLink(doc, oldHref, newHref) {
168
- const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
169
- const spans = [];
170
- visit(tree, 'link', (node) => {
171
- if (node.url !== oldHref)
172
- return;
173
- const start = node.position?.start?.offset;
174
- const end = node.position?.end?.offset;
175
- if (start == null || end == null)
176
- return;
177
- spans.push({ start, end });
178
- });
179
- spans.sort((a, b) => b.start - a.start);
180
- let out = doc;
181
- for (const span of spans) {
182
- const src = out.slice(span.start, span.end);
183
- const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
184
- out = out.slice(0, span.start) + rewritten + out.slice(span.end);
185
- }
186
- return out;
187
- }
@@ -18,3 +18,11 @@ export declare function escapeLinkText(text: string): string;
18
18
  /** The cairn links a markdown body points at, in first-occurrence order, deduped by concept/id.
19
19
  * Parses the body as mdast, so a token inside a code span or fence is never matched. */
20
20
  export declare function extractCairnLinks(body: string): CairnRef[];
21
+ /**
22
+ * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
23
+ * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
24
+ * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
25
+ * span is not a link node and is never touched. Each matching node's source span is rewritten from
26
+ * last to first, replacing only the `](oldHref` run so the label and title stay exact.
27
+ */
28
+ export declare function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string;
@@ -50,3 +50,31 @@ export function extractCairnLinks(body) {
50
50
  });
51
51
  return refs;
52
52
  }
53
+ /**
54
+ * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
55
+ * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
56
+ * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
57
+ * span is not a link node and is never touched. Each matching node's source span is rewritten from
58
+ * last to first, replacing only the `](oldHref` run so the label and title stay exact.
59
+ */
60
+ export function rewriteCairnLink(doc, oldHref, newHref) {
61
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
62
+ const spans = [];
63
+ visit(tree, 'link', (node) => {
64
+ if (node.url !== oldHref)
65
+ return;
66
+ const start = node.position?.start?.offset;
67
+ const end = node.position?.end?.offset;
68
+ if (start == null || end == null)
69
+ return;
70
+ spans.push({ start, end });
71
+ });
72
+ spans.sort((a, b) => b.start - a.start);
73
+ let out = doc;
74
+ for (const span of spans) {
75
+ const src = out.slice(span.start, span.end);
76
+ const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
77
+ out = out.slice(0, span.start) + rewritten + out.slice(span.end);
78
+ }
79
+ return out;
80
+ }
@@ -154,8 +154,8 @@ export interface CairnAdapter {
154
154
  backend: BackendConfig;
155
155
  sender: SenderConfig;
156
156
  /** The site's one renderer: the editor preview and every public page call it (design decision 4).
157
- * `resolve` rewrites cairn: links to live permalinks; the build passes a site-index resolver, the
158
- * preview a manifest one. */
157
+ * `resolve` rewrites cairn: links to live permalinks; the build passes a site-resolver-backed
158
+ * one, the preview a manifest one. */
159
159
  render(md: string, opts?: {
160
160
  stagger?: boolean;
161
161
  resolve?: LinkResolve;
@@ -1,7 +1,7 @@
1
1
  export { createContentIndex, fromGlob } from './content-index.js';
2
2
  export type { RawFile, ContentSummary, ContentEntry, ContentIndex, ContentProblem } from './content-index.js';
3
- export { createSiteIndex } from './site-index.js';
4
- export type { SiteIndex, ConceptIndex } from './site-index.js';
3
+ export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
4
+ export type { SiteResolver, ConceptIndex } from './site-resolver.js';
5
5
  export { createSiteIndexes } from './site-indexes.js';
6
6
  export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
7
7
  export { siteDescriptors } from './site-descriptors.js';
@@ -15,9 +15,7 @@ export { buildSeoMeta } from './seo.js';
15
15
  export type { SeoInput, SeoMeta } from './seo.js';
16
16
  export { readSeoFields, resolveImageUrl } from './seo-fields.js';
17
17
  export type { SeoFields } from './seo-fields.js';
18
- export { paginate } from './paginate.js';
19
- export type { Page } from './paginate.js';
20
18
  export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
21
19
  export { jsonLdScript } from './json-ld.js';
22
20
  export { permalink } from '../content/permalink.js';
23
- export { buildSiteManifest, buildLinkResolver } from './manifest.js';
21
+ export { buildSiteManifest } from './manifest.js';
@@ -2,7 +2,7 @@
2
2
  // projections a SvelteKit site or a plain-Node tool reads, with no @sveltejs/kit and no .svelte in
3
3
  // the graph. The full ./delivery barrel re-exports this and adds the route loaders.
4
4
  export { createContentIndex, fromGlob } from './content-index.js';
5
- export { createSiteIndex } from './site-index.js';
5
+ export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
6
6
  export { createSiteIndexes } from './site-indexes.js';
7
7
  export { siteDescriptors } from './site-descriptors.js';
8
8
  export { deriveExcerpt, wordCount } from './excerpt.js';
@@ -11,8 +11,7 @@ export { buildSitemap } from './sitemap.js';
11
11
  export { buildRobots } from './robots.js';
12
12
  export { buildSeoMeta } from './seo.js';
13
13
  export { readSeoFields, resolveImageUrl } from './seo-fields.js';
14
- export { paginate } from './paginate.js';
15
14
  export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
16
15
  export { jsonLdScript } from './json-ld.js';
17
16
  export { permalink } from '../content/permalink.js';
18
- export { buildSiteManifest, buildLinkResolver } from './manifest.js';
17
+ export { buildSiteManifest } from './manifest.js';
@@ -2,13 +2,7 @@
2
2
  // channel and a list of items, so they unit-test without a render or a network. The caller
3
3
  // (a template +server.ts shim) assembles items from the content index and passes absolute
4
4
  // URLs built from PUBLIC_ORIGIN.
5
- function escapeXml(value) {
6
- return value
7
- .replace(/&/g, '&amp;')
8
- .replace(/</g, '&lt;')
9
- .replace(/>/g, '&gt;')
10
- .replace(/"/g, '&quot;');
11
- }
5
+ import { escapeXml } from './xml.js';
12
6
  /** Make a string safe inside a CDATA section by splitting any `]]>` across two sections. */
13
7
  function cdataSafe(value) {
14
8
  return value.replace(/]]>/g, ']]]]><![CDATA[>');
@@ -1,3 +1,3 @@
1
1
  export * from './data.js';
2
- export { createPublicRoutes } from '../sveltekit/public-routes.js';
3
- export type { PublicRoutesDeps, ListData, TagData, TagIndexData, EntryData, } from '../sveltekit/public-routes.js';
2
+ export { createPublicRoutes } from './public-routes.js';
3
+ export type { PublicRoutesDeps, ListData, TagData, TagIndexData, EntryData, } from './public-routes.js';
@@ -3,4 +3,4 @@
3
3
  // lives at ./delivery/head. Importing this pulls @sveltejs/kit through the route loaders, so a
4
4
  // plain-Node tool imports from ./delivery/data instead.
5
5
  export * from './data.js';
6
- export { createPublicRoutes } from '../sveltekit/public-routes.js';
6
+ export { createPublicRoutes } from './public-routes.js';