@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.41.0",
3
+ "version": "0.50.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -80,6 +80,10 @@
80
80
  "types": "./dist/vite/index.d.ts",
81
81
  "default": "./dist/vite/index.js"
82
82
  },
83
+ "./ambient": {
84
+ "types": "./dist/ambient.d.ts",
85
+ "default": "./dist/ambient.js"
86
+ },
83
87
  "./package.json": "./package.json"
84
88
  },
85
89
  "bin": {
@@ -0,0 +1,19 @@
1
+ // The one-line App.Locals augmentation a consumer site imports from src/app.d.ts:
2
+ //
3
+ // import '@glw907/cairn-cms/ambient';
4
+ //
5
+ // The guard sets `event.locals.editor`, and this declaration types it, so a site no longer
6
+ // hand-writes the `declare global` block. The field is optional: the engine's own structural
7
+ // event types read it as `editor?: Editor | null`, and a request the guard has not touched
8
+ // carries no editor at all.
9
+ import type { Editor } from './auth/types.js';
10
+
11
+ declare global {
12
+ namespace App {
13
+ interface Locals {
14
+ editor?: Editor | null;
15
+ }
16
+ }
17
+ }
18
+
19
+ 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}
@@ -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();
@@ -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
  <!--
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>
@@ -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>
@@ -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';
@@ -197,30 +197,3 @@ export function unwrapCairnLink(doc: string, href: string): string {
197
197
  }
198
198
  return out;
199
199
  }
200
-
201
- /**
202
- * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
203
- * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
204
- * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
205
- * span is not a link node and is never touched. Each matching node's source span is rewritten from
206
- * last to first, replacing only the `](oldHref` run so the label and title stay exact.
207
- */
208
- export function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string {
209
- const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
210
- const spans: { start: number; end: number }[] = [];
211
- visit(tree, 'link', (node: Link) => {
212
- if (node.url !== oldHref) return;
213
- const start = node.position?.start?.offset;
214
- const end = node.position?.end?.offset;
215
- if (start == null || end == null) return;
216
- spans.push({ start, end });
217
- });
218
- spans.sort((a, b) => b.start - a.start);
219
- let out = doc;
220
- for (const span of spans) {
221
- const src = out.slice(span.start, span.end);
222
- const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
223
- out = out.slice(0, span.start) + rewritten + out.slice(span.end);
224
- }
225
- return out;
226
- }
@@ -6,6 +6,7 @@ import { unified } from 'unified';
6
6
  import remarkParse from 'remark-parse';
7
7
  import remarkGfm from 'remark-gfm';
8
8
  import { visit } from 'unist-util-visit';
9
+ import type { Link } from 'mdast';
9
10
  import { isValidId } from './ids.js';
10
11
 
11
12
  /** A resolved reference to a content entry by its concept and permanent id. */
@@ -59,3 +60,30 @@ export function extractCairnLinks(body: string): CairnRef[] {
59
60
  });
60
61
  return refs;
61
62
  }
63
+
64
+ /**
65
+ * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
66
+ * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
67
+ * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
68
+ * span is not a link node and is never touched. Each matching node's source span is rewritten from
69
+ * last to first, replacing only the `](oldHref` run so the label and title stay exact.
70
+ */
71
+ export function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string {
72
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
73
+ const spans: { start: number; end: number }[] = [];
74
+ visit(tree, 'link', (node: Link) => {
75
+ if (node.url !== oldHref) return;
76
+ const start = node.position?.start?.offset;
77
+ const end = node.position?.end?.offset;
78
+ if (start == null || end == null) return;
79
+ spans.push({ start, end });
80
+ });
81
+ spans.sort((a, b) => b.start - a.start);
82
+ let out = doc;
83
+ for (const span of spans) {
84
+ const src = out.slice(span.start, span.end);
85
+ const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
86
+ out = out.slice(0, span.start) + rewritten + out.slice(span.end);
87
+ }
88
+ return out;
89
+ }
@@ -176,8 +176,8 @@ export interface CairnAdapter {
176
176
  backend: BackendConfig;
177
177
  sender: SenderConfig;
178
178
  /** The site's one renderer: the editor preview and every public page call it (design decision 4).
179
- * `resolve` rewrites cairn: links to live permalinks; the build passes a site-index resolver, the
180
- * preview a manifest one. */
179
+ * `resolve` rewrites cairn: links to live permalinks; the build passes a site-resolver-backed
180
+ * one, the preview a manifest one. */
181
181
  render(md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }): string | Promise<string>;
182
182
  /** Repo-relative path to the committed content manifest. Defaults to src/content/.cairn/index.json
183
183
  * in composeRuntime. It sits outside any concept directory, so content enumeration never globs it. */
@@ -3,8 +3,8 @@
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
5
  export type { RawFile, ContentSummary, ContentEntry, ContentIndex, ContentProblem } from './content-index.js';
6
- export { createSiteIndex } from './site-index.js';
7
- export type { SiteIndex, ConceptIndex } from './site-index.js';
6
+ export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
7
+ export type { SiteResolver, ConceptIndex } from './site-resolver.js';
8
8
  export { createSiteIndexes } from './site-indexes.js';
9
9
  export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
10
10
  export { siteDescriptors } from './site-descriptors.js';
@@ -18,9 +18,7 @@ export { buildSeoMeta } from './seo.js';
18
18
  export type { SeoInput, SeoMeta } from './seo.js';
19
19
  export { readSeoFields, resolveImageUrl } from './seo-fields.js';
20
20
  export type { SeoFields } from './seo-fields.js';
21
- export { paginate } from './paginate.js';
22
- export type { Page } from './paginate.js';
23
21
  export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
24
22
  export { jsonLdScript } from './json-ld.js';
25
23
  export { permalink } from '../content/permalink.js';
26
- export { buildSiteManifest, buildLinkResolver } from './manifest.js';
24
+ export { buildSiteManifest } from './manifest.js';
@@ -2,6 +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
+ import { escapeXml } from './xml.js';
5
6
 
6
7
  /** Feed channel metadata. URLs are absolute. */
7
8
  export interface FeedChannel {
@@ -24,14 +25,6 @@ export interface FeedItem {
24
25
  tags?: string[];
25
26
  }
26
27
 
27
- function escapeXml(value: string): string {
28
- return value
29
- .replace(/&/g, '&amp;')
30
- .replace(/</g, '&lt;')
31
- .replace(/>/g, '&gt;')
32
- .replace(/"/g, '&quot;');
33
- }
34
-
35
28
  /** Make a string safe inside a CDATA section by splitting any `]]>` across two sections. */
36
29
  function cdataSafe(value: string): string {
37
30
  return value.replace(/]]>/g, ']]]]><![CDATA[>');
@@ -3,11 +3,11 @@
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';
7
7
  export type {
8
8
  PublicRoutesDeps,
9
9
  ListData,
10
10
  TagData,
11
11
  TagIndexData,
12
12
  EntryData,
13
- } from '../sveltekit/public-routes.js';
13
+ } from './public-routes.js';
@@ -1,15 +1,12 @@
1
- // cairn-cms: the build-side manifest builder and the build link resolver (content-graph design).
2
- // buildSiteManifest mirrors createSiteIndexes: it maps the site descriptors over the per-concept
3
- // globs and projects each file to a manifest row. buildLinkResolver reads the site index, which is
4
- // fresh from the files at build, and throws on a missing target so a dangling cairn: token fails
5
- // the build (the backstop). The admin preview uses manifestLinkResolver instead.
1
+ // cairn-cms: the build-side manifest builder (content-graph design). buildSiteManifest mirrors
2
+ // createSiteIndexes: it maps the site descriptors over the per-concept globs and projects each
3
+ // file to a manifest row. The build-time cairn: link resolver lives beside the site resolver in
4
+ // site-resolver.ts; the admin preview uses manifestLinkResolver instead.
6
5
  import { siteDescriptors } from './site-descriptors.js';
7
6
  import { fromGlob } from './content-index.js';
8
7
  import { parseMarkdown } from '../content/frontmatter.js';
9
8
  import { emptyManifest, manifestEntryFromFile } from '../content/manifest.js';
10
9
  import type { Manifest } from '../content/manifest.js';
11
- import type { LinkResolve } from '../content/links.js';
12
- import type { SiteIndex } from './site-index.js';
13
10
  import type { SiteConfig } from '../nav/site-config.js';
14
11
  import type { CairnAdapter } from '../content/types.js';
15
12
  import type { SiteGlobs } from './site-indexes.js';
@@ -22,7 +19,7 @@ export function buildSiteManifest<A extends CairnAdapter>(adapter: A, config: Si
22
19
  for (const descriptor of siteDescriptors(adapter, config)) {
23
20
  const record = globRecord[descriptor.id] ?? {};
24
21
  for (const file of fromGlob(record)) {
25
- // Validate the same way createContentIndex does, so the manifest and the site index agree on
22
+ // Validate the same way createContentIndex does, so the manifest and the site resolver agree on
26
23
  // which entries exist. A validation failure is excluded from both; otherwise the preview would
27
24
  // resolve a link the build then rejects as a missing target.
28
25
  const { frontmatter, body } = parseMarkdown(file.raw);
@@ -32,13 +29,3 @@ export function buildSiteManifest<A extends CairnAdapter>(adapter: A, config: Si
32
29
  }
33
30
  return manifest;
34
31
  }
35
-
36
- /** A resolver backed by the site index, for the build. A miss throws, so a dangling cairn: token
37
- * fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
38
- export function buildLinkResolver(site: SiteIndex): LinkResolve {
39
- return (ref) => {
40
- const url = site.concept(ref.concept)?.byId(ref.id)?.permalink;
41
- if (!url) throw new Error(`cairn link target not found: cairn:${ref.concept}/${ref.id}`);
42
- return url;
43
- };
44
- }
@@ -1,20 +1,20 @@
1
- // cairn-cms: public route loaders (dated-slug design). The factory closes over the site-level
2
- // index, the runtime render, and the origin. entryLoad and entries are site-wide: one catch-all
1
+ // cairn-cms: public route loaders (dated-slug design). The factory closes over the site
2
+ // resolver, the runtime render, and the origin. entryLoad and entries are site-wide: one catch-all
3
3
  // `[...path]` route resolves any concept by request path through `byPermalink`. The archive, tag,
4
- // and tag-index loaders stay concept-scoped, keyed by concept id. The index is built in site code
5
- // from globs, so it stays in the prerender graph and out of the runtime Worker.
4
+ // and tag-index loaders stay concept-scoped, keyed by concept id. The resolver is built in site
5
+ // code from globs, so it stays in the prerender graph and out of the runtime Worker.
6
6
  import { error } from '@sveltejs/kit';
7
- import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
8
- import type { SiteIndex } from '../delivery/site-index.js';
9
- import { buildSeoMeta } from '../delivery/seo.js';
10
- import type { SeoMeta } from '../delivery/seo.js';
11
- import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
12
- import { buildLinkResolver } from '../delivery/manifest.js';
7
+ import type { ContentSummary, ContentEntry } from './content-index.js';
8
+ import type { SiteResolver } from './site-resolver.js';
9
+ import { buildSeoMeta } from './seo.js';
10
+ import type { SeoMeta } from './seo.js';
11
+ import { readSeoFields, resolveImageUrl } from './seo-fields.js';
12
+ import { buildLinkResolver } from './site-resolver.js';
13
13
  import type { LinkResolve } from '../content/links.js';
14
14
 
15
15
  /** Injected dependencies for the public loaders. */
16
16
  export interface PublicRoutesDeps {
17
- site: SiteIndex;
17
+ site: SiteResolver;
18
18
  render: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
19
19
  origin: string;
20
20
  /** Site name for og:site_name and the SEO head. */
@@ -1,7 +1,7 @@
1
1
  // cairn-cms: the full-auto typed site index (schema-source-of-truth design). It maps over a
2
2
  // defineAdapter-typed adapter to give one typed per-concept index, with frontmatter typed as the
3
3
  // concept's inferred schema type, plus a site resolver for the catch-all route. It is the typed
4
- // convenience over createContentIndex and createSiteIndex, not a replacement: both stay the
4
+ // convenience over createContentIndex and createSiteResolver, not a replacement: both stay the
5
5
  // lower-level escape hatch. It imports only pure content and delivery code, so the delivery
6
6
  // bundle stays backend-free.
7
7
  import type { CairnAdapter, ConceptConfig } from '../content/types.js';
@@ -9,9 +9,9 @@ import type { Infer } from '../content/schema.js';
9
9
  import type { SiteConfig } from '../nav/site-config.js';
10
10
  import { siteDescriptors } from './site-descriptors.js';
11
11
  import { createContentIndex, fromGlob } from './content-index.js';
12
- import { createSiteIndex } from './site-index.js';
12
+ import { createSiteResolver } from './site-resolver.js';
13
13
  import type { ContentIndex } from './content-index.js';
14
- import type { ConceptIndex, SiteIndex } from './site-index.js';
14
+ import type { ConceptIndex, SiteResolver } from './site-resolver.js';
15
15
 
16
16
  /** A per-concept raw glob record (`{ path: raw }`) keyed by concept id, from `import.meta.glob`. */
17
17
  export type SiteGlobs<A extends CairnAdapter> = {
@@ -24,13 +24,13 @@ export type SiteIndexes<A extends CairnAdapter> = {
24
24
  [K in keyof A['content']]: ContentIndex<
25
25
  NonNullable<A['content'][K]> extends ConceptConfig<infer S> ? Infer<S> : Record<string, unknown>
26
26
  >;
27
- } & { readonly site: SiteIndex };
27
+ } & { readonly site: SiteResolver };
28
28
 
29
29
  /**
30
30
  * Build typed per-concept indexes and a site resolver from one adapter. Pass the per-concept raw
31
31
  * globs as `{ posts: import.meta.glob('...?raw', { eager: true }), ... }`; Vite needs the literal
32
32
  * glob at the call site, so the engine cannot glob on the site's behalf. `validate: false` opts out
33
- * of the build gate, exactly as on `createSiteIndex`.
33
+ * of the build gate, exactly as on `createSiteResolver`.
34
34
  */
35
35
  export function createSiteIndexes<const A extends CairnAdapter>(
36
36
  adapter: A,
@@ -59,6 +59,6 @@ export function createSiteIndexes<const A extends CairnAdapter>(
59
59
  byConcept[descriptor.id] = index;
60
60
  conceptIndexes.push({ descriptor, index });
61
61
  }
62
- const site = createSiteIndex(conceptIndexes, opts);
62
+ const site = createSiteResolver(conceptIndexes, opts);
63
63
  return { ...byConcept, site } as SiteIndexes<A>;
64
64
  }
@@ -1,9 +1,11 @@
1
- // cairn-cms: the site-level content index (dated-slug design). It unions every concept's
2
- // per-concept index into one cross-concept resolver: a single byPermalink map a catch-all route
3
- // matches a request path against, one entries() list the prerenderer walks, and the per-concept
4
- // indexes for concept-scoped archive, tag, and feed loaders. A duplicate permalink throws at build.
1
+ // cairn-cms: the cross-concept site resolver (dated-slug design). It unions every concept's
2
+ // per-concept index into one resolver: a single byPermalink map a catch-all route matches a
3
+ // request path against, one entries() list the prerenderer walks, and the per-concept indexes
4
+ // for concept-scoped archive, tag, and feed loaders. A duplicate permalink throws at build.
5
+ // buildLinkResolver lives here too, since it closes over the resolver.
5
6
  import type { ConceptDescriptor } from '../content/types.js';
6
7
  import type { ContentEntry, ContentIndex, ContentSummary } from './content-index.js';
8
+ import type { LinkResolve } from '../content/links.js';
7
9
 
8
10
  /** One concept's descriptor paired with its built index. */
9
11
  export interface ConceptIndex {
@@ -12,7 +14,7 @@ export interface ConceptIndex {
12
14
  }
13
15
 
14
16
  /** The cross-concept query surface a catch-all route and the sitemap read. */
15
- export interface SiteIndex {
17
+ export interface SiteResolver {
16
18
  /** Resolve a request path (with or without a trailing slash) to its entry, or undefined. */
17
19
  byPermalink(path: string): ContentEntry | undefined;
18
20
  /** Newer/older neighbors within the entry's own concept, for prev/next links. */
@@ -49,11 +51,11 @@ function siteProblems(concepts: ConceptIndex[]): string[] {
49
51
  * unless `validate` is `false`, on any non-draft entry whose frontmatter fails its concept's
50
52
  * validator, so malformed content fails the build instead of shipping.
51
53
  */
52
- export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boolean } = {}): SiteIndex {
54
+ export function createSiteResolver(concepts: ConceptIndex[], opts: { validate?: boolean } = {}): SiteResolver {
53
55
  if (opts.validate !== false) {
54
56
  const problems = siteProblems(concepts);
55
57
  if (problems.length > 0) {
56
- throw new Error(`site index: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
58
+ throw new Error(`site resolver: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
57
59
  }
58
60
  }
59
61
  const byPath = new Map<string, { index: ContentIndex; id: string }>();
@@ -64,7 +66,7 @@ export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boo
64
66
  const existing = byPath.get(summary.permalink);
65
67
  if (existing) {
66
68
  throw new Error(
67
- `site index: "${existing.id}" and "${summary.id}" both resolve to "${summary.permalink}"`,
69
+ `site resolver: "${existing.id}" and "${summary.id}" both resolve to "${summary.permalink}"`,
68
70
  );
69
71
  }
70
72
  byPath.set(summary.permalink, { index, id: summary.id });
@@ -90,3 +92,13 @@ export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boo
90
92
  },
91
93
  };
92
94
  }
95
+
96
+ /** A resolver backed by the site resolver, for the build. A miss throws, so a dangling cairn: token
97
+ * fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
98
+ export function buildLinkResolver(site: SiteResolver): LinkResolve {
99
+ return (ref) => {
100
+ const url = site.concept(ref.concept)?.byId(ref.id)?.permalink;
101
+ if (!url) throw new Error(`cairn link target not found: cairn:${ref.concept}/${ref.id}`);
102
+ return url;
103
+ };
104
+ }