@glw907/cairn-cms 0.5.1 → 0.6.0-rc.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 (233) hide show
  1. package/dist/auth/crypto.d.ts +13 -0
  2. package/dist/auth/crypto.d.ts.map +1 -0
  3. package/dist/auth/crypto.js +31 -0
  4. package/dist/auth/store.d.ts +41 -0
  5. package/dist/auth/store.d.ts.map +1 -0
  6. package/dist/auth/store.js +115 -0
  7. package/dist/auth/types.d.ts +25 -0
  8. package/dist/auth/types.d.ts.map +1 -0
  9. package/dist/auth/types.js +1 -0
  10. package/dist/components/AdminLayout.svelte +58 -164
  11. package/dist/components/AdminLayout.svelte.d.ts +14 -18
  12. package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
  13. package/dist/components/ComponentPalette.svelte +36 -20
  14. package/dist/components/ComponentPalette.svelte.d.ts +11 -4
  15. package/dist/components/ComponentPalette.svelte.d.ts.map +1 -1
  16. package/dist/components/ConceptList.svelte +81 -0
  17. package/dist/components/ConceptList.svelte.d.ts +13 -0
  18. package/dist/components/ConceptList.svelte.d.ts.map +1 -0
  19. package/dist/components/ConfirmPage.svelte +23 -20
  20. package/dist/components/ConfirmPage.svelte.d.ts +6 -0
  21. package/dist/components/ConfirmPage.svelte.d.ts.map +1 -1
  22. package/dist/components/EditPage.svelte +155 -136
  23. package/dist/components/EditPage.svelte.d.ts +16 -8
  24. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  25. package/dist/components/LoginPage.svelte +42 -52
  26. package/dist/components/LoginPage.svelte.d.ts +12 -0
  27. package/dist/components/LoginPage.svelte.d.ts.map +1 -1
  28. package/dist/components/ManageEditors.svelte +81 -0
  29. package/dist/components/ManageEditors.svelte.d.ts +24 -0
  30. package/dist/components/ManageEditors.svelte.d.ts.map +1 -0
  31. package/dist/components/MarkdownEditor.svelte +81 -0
  32. package/dist/components/MarkdownEditor.svelte.d.ts +20 -0
  33. package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -0
  34. package/dist/components/NavTree.svelte +73 -63
  35. package/dist/components/NavTree.svelte.d.ts +13 -4
  36. package/dist/components/NavTree.svelte.d.ts.map +1 -1
  37. package/dist/components/cairn-admin.css +42 -0
  38. package/dist/components/index.d.ts +3 -2
  39. package/dist/components/index.d.ts.map +1 -1
  40. package/dist/components/index.js +5 -4
  41. package/dist/content/compose.d.ts +7 -0
  42. package/dist/content/compose.d.ts.map +1 -0
  43. package/dist/content/compose.js +32 -0
  44. package/dist/content/concepts.d.ts +17 -0
  45. package/dist/content/concepts.d.ts.map +1 -0
  46. package/dist/content/concepts.js +41 -0
  47. package/dist/content/frontmatter.d.ts +18 -0
  48. package/dist/content/frontmatter.d.ts.map +1 -0
  49. package/dist/content/frontmatter.js +58 -0
  50. package/dist/content/ids.d.ts +17 -0
  51. package/dist/content/ids.d.ts.map +1 -0
  52. package/dist/content/ids.js +33 -0
  53. package/dist/content/types.d.ts +210 -0
  54. package/dist/content/types.d.ts.map +1 -0
  55. package/dist/content/types.js +1 -0
  56. package/dist/content/validate.d.ts +13 -0
  57. package/dist/content/validate.d.ts.map +1 -0
  58. package/dist/content/validate.js +45 -0
  59. package/dist/email.d.ts +25 -12
  60. package/dist/email.d.ts.map +1 -1
  61. package/dist/email.js +24 -24
  62. package/dist/env.d.ts +24 -0
  63. package/dist/env.d.ts.map +1 -0
  64. package/dist/env.js +29 -0
  65. package/dist/github/credentials.d.ts +12 -0
  66. package/dist/github/credentials.d.ts.map +1 -0
  67. package/dist/github/credentials.js +11 -0
  68. package/dist/github/repo.d.ts +49 -0
  69. package/dist/github/repo.d.ts.map +1 -0
  70. package/dist/github/repo.js +123 -0
  71. package/dist/github/signing.d.ts +17 -0
  72. package/dist/github/signing.d.ts.map +1 -0
  73. package/dist/github/signing.js +79 -0
  74. package/dist/github/types.d.ts +35 -0
  75. package/dist/github/types.d.ts.map +1 -0
  76. package/dist/github/types.js +19 -0
  77. package/dist/index.d.ts +27 -8
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +21 -10
  80. package/dist/{nav.d.ts → nav/site-config.d.ts} +16 -24
  81. package/dist/nav/site-config.d.ts.map +1 -0
  82. package/dist/{nav.js → nav/site-config.js} +27 -13
  83. package/dist/render/glyph.d.ts +1 -1
  84. package/dist/render/glyph.d.ts.map +1 -1
  85. package/dist/render/index.d.ts +5 -5
  86. package/dist/render/index.d.ts.map +1 -1
  87. package/dist/render/index.js +6 -6
  88. package/dist/render/pipeline.d.ts +3 -3
  89. package/dist/render/pipeline.d.ts.map +1 -1
  90. package/dist/render/pipeline.js +4 -4
  91. package/dist/render/registry.d.ts +6 -4
  92. package/dist/render/registry.d.ts.map +1 -1
  93. package/dist/render/registry.js +8 -6
  94. package/dist/render/rehype-dispatch.d.ts +1 -1
  95. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  96. package/dist/render/remark-directives.d.ts +1 -1
  97. package/dist/render/remark-directives.d.ts.map +1 -1
  98. package/dist/render/sanitize.d.ts +8 -0
  99. package/dist/render/sanitize.d.ts.map +1 -0
  100. package/dist/render/sanitize.js +26 -0
  101. package/dist/sveltekit/auth-routes.d.ts +23 -0
  102. package/dist/sveltekit/auth-routes.d.ts.map +1 -0
  103. package/dist/sveltekit/auth-routes.js +85 -0
  104. package/dist/sveltekit/content-routes.d.ts +80 -0
  105. package/dist/sveltekit/content-routes.d.ts.map +1 -0
  106. package/dist/sveltekit/content-routes.js +183 -0
  107. package/dist/sveltekit/editors-routes.d.ts +24 -0
  108. package/dist/sveltekit/editors-routes.d.ts.map +1 -0
  109. package/dist/sveltekit/editors-routes.js +73 -0
  110. package/dist/sveltekit/guard.d.ts +9 -0
  111. package/dist/sveltekit/guard.d.ts.map +1 -0
  112. package/dist/sveltekit/guard.js +43 -0
  113. package/dist/sveltekit/health.d.ts +19 -0
  114. package/dist/sveltekit/health.d.ts.map +1 -0
  115. package/dist/sveltekit/health.js +12 -0
  116. package/dist/sveltekit/index.d.ts +9 -173
  117. package/dist/sveltekit/index.d.ts.map +1 -1
  118. package/dist/sveltekit/index.js +8 -348
  119. package/dist/sveltekit/nav-routes.d.ts +30 -0
  120. package/dist/sveltekit/nav-routes.d.ts.map +1 -0
  121. package/dist/sveltekit/nav-routes.js +103 -0
  122. package/dist/sveltekit/types.d.ts +32 -0
  123. package/dist/sveltekit/types.d.ts.map +1 -0
  124. package/dist/sveltekit/types.js +1 -0
  125. package/package.json +32 -57
  126. package/src/lib/auth/crypto.ts +37 -0
  127. package/src/lib/auth/store.ts +158 -0
  128. package/src/lib/auth/types.ts +27 -0
  129. package/src/lib/components/AdminLayout.svelte +58 -164
  130. package/src/lib/components/ComponentPalette.svelte +36 -20
  131. package/src/lib/components/ConceptList.svelte +81 -0
  132. package/src/lib/components/ConfirmPage.svelte +23 -20
  133. package/src/lib/components/EditPage.svelte +155 -136
  134. package/src/lib/components/LoginPage.svelte +42 -52
  135. package/src/lib/components/ManageEditors.svelte +81 -0
  136. package/src/lib/components/MarkdownEditor.svelte +81 -0
  137. package/src/lib/components/NavTree.svelte +73 -63
  138. package/src/lib/components/cairn-admin.css +42 -0
  139. package/src/lib/components/index.ts +5 -4
  140. package/src/lib/content/compose.ts +39 -0
  141. package/src/lib/content/concepts.ts +57 -0
  142. package/src/lib/content/frontmatter.ts +71 -0
  143. package/src/lib/content/ids.ts +38 -0
  144. package/src/lib/content/types.ts +235 -0
  145. package/src/lib/content/validate.ts +51 -0
  146. package/src/lib/email.ts +52 -38
  147. package/src/lib/env.ts +32 -0
  148. package/src/lib/github/credentials.ts +27 -0
  149. package/src/lib/github/repo.ts +138 -0
  150. package/src/lib/github/signing.ts +97 -0
  151. package/src/lib/github/types.ts +46 -0
  152. package/src/lib/index.ts +86 -10
  153. package/src/lib/{nav.ts → nav/site-config.ts} +31 -24
  154. package/src/lib/render/glyph.ts +6 -6
  155. package/src/lib/render/index.ts +6 -6
  156. package/src/lib/render/pipeline.ts +22 -22
  157. package/src/lib/render/registry.ts +33 -26
  158. package/src/lib/render/rehype-dispatch.ts +47 -47
  159. package/src/lib/render/remark-directives.ts +46 -46
  160. package/src/lib/render/sanitize.ts +27 -0
  161. package/src/lib/sveltekit/auth-routes.ts +107 -0
  162. package/src/lib/sveltekit/content-routes.ts +261 -0
  163. package/src/lib/sveltekit/editors-routes.ts +82 -0
  164. package/src/lib/sveltekit/guard.ts +47 -0
  165. package/src/lib/sveltekit/health.ts +24 -0
  166. package/src/lib/sveltekit/index.ts +19 -512
  167. package/src/lib/sveltekit/nav-routes.ts +139 -0
  168. package/src/lib/sveltekit/types.ts +33 -0
  169. package/dist/adapter.d.ts +0 -93
  170. package/dist/adapter.d.ts.map +0 -1
  171. package/dist/adapter.js +0 -30
  172. package/dist/auth/admins.d.ts +0 -33
  173. package/dist/auth/admins.d.ts.map +0 -1
  174. package/dist/auth/admins.js +0 -90
  175. package/dist/auth/capabilities.d.ts +0 -7
  176. package/dist/auth/capabilities.d.ts.map +0 -1
  177. package/dist/auth/capabilities.js +0 -26
  178. package/dist/auth/config.d.ts +0 -2097
  179. package/dist/auth/config.d.ts.map +0 -1
  180. package/dist/auth/config.js +0 -78
  181. package/dist/auth/guard.d.ts +0 -34
  182. package/dist/auth/guard.d.ts.map +0 -1
  183. package/dist/auth/guard.js +0 -47
  184. package/dist/auth/index.d.ts +0 -5
  185. package/dist/auth/index.d.ts.map +0 -1
  186. package/dist/auth/index.js +0 -7
  187. package/dist/auth/schema.d.ts +0 -750
  188. package/dist/auth/schema.d.ts.map +0 -1
  189. package/dist/auth/schema.js +0 -93
  190. package/dist/carta.d.ts +0 -39
  191. package/dist/carta.d.ts.map +0 -1
  192. package/dist/carta.js +0 -30
  193. package/dist/components/CollectionList.svelte +0 -96
  194. package/dist/components/CollectionList.svelte.d.ts +0 -8
  195. package/dist/components/CollectionList.svelte.d.ts.map +0 -1
  196. package/dist/components/ManageAdmins.svelte +0 -84
  197. package/dist/components/ManageAdmins.svelte.d.ts +0 -10
  198. package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
  199. package/dist/content.d.ts +0 -3
  200. package/dist/content.d.ts.map +0 -1
  201. package/dist/content.js +0 -10
  202. package/dist/editor.d.ts +0 -25
  203. package/dist/editor.d.ts.map +0 -1
  204. package/dist/editor.js +0 -20
  205. package/dist/frontmatter.d.ts +0 -3
  206. package/dist/frontmatter.d.ts.map +0 -1
  207. package/dist/frontmatter.js +0 -16
  208. package/dist/github.d.ts +0 -72
  209. package/dist/github.d.ts.map +0 -1
  210. package/dist/github.js +0 -171
  211. package/dist/nav.d.ts.map +0 -1
  212. package/dist/slug.d.ts +0 -7
  213. package/dist/slug.d.ts.map +0 -1
  214. package/dist/slug.js +0 -15
  215. package/dist/utils.d.ts +0 -3
  216. package/dist/utils.d.ts.map +0 -1
  217. package/dist/utils.js +0 -11
  218. package/src/lib/adapter.ts +0 -144
  219. package/src/lib/auth/admins.ts +0 -106
  220. package/src/lib/auth/capabilities.ts +0 -35
  221. package/src/lib/auth/config.ts +0 -108
  222. package/src/lib/auth/guard.ts +0 -60
  223. package/src/lib/auth/index.ts +0 -7
  224. package/src/lib/auth/schema.ts +0 -112
  225. package/src/lib/carta.ts +0 -59
  226. package/src/lib/components/CollectionList.svelte +0 -96
  227. package/src/lib/components/ManageAdmins.svelte +0 -84
  228. package/src/lib/content.ts +0 -11
  229. package/src/lib/editor.ts +0 -38
  230. package/src/lib/frontmatter.ts +0 -17
  231. package/src/lib/github.ts +0 -220
  232. package/src/lib/slug.ts +0 -16
  233. package/src/lib/utils.ts +0 -12
@@ -0,0 +1,81 @@
1
+ <!--
2
+ @component
3
+ The owner-gated editor management surface: a table of editors with role-flip and remove actions,
4
+ and an add-editor form. The acting owner's own row disables its destructive controls; the
5
+ last-owner anti-lockout rule itself is enforced server-side (editors-routes). Actions post to the
6
+ named `?/setRole`, `?/remove`, and `?/add` actions.
7
+ -->
8
+ <script lang="ts">
9
+ import type { Editor } from '../auth/types.js';
10
+
11
+ interface Props {
12
+ /** The editors load's data, plus the site name. */
13
+ data: { editors: Editor[]; self: string; siteName: string };
14
+ /** The last action's result (an error message when it failed). */
15
+ form: { error?: string; ok?: boolean } | null;
16
+ }
17
+
18
+ let { data, form }: Props = $props();
19
+ </script>
20
+
21
+ <header class="mb-4">
22
+ <h1 class="text-xl font-semibold">Editors</h1>
23
+ </header>
24
+
25
+ {#if form?.error}
26
+ <div role="alert" class="alert alert-error mb-4 text-sm">{form.error}</div>
27
+ {/if}
28
+
29
+ <div class="overflow-x-auto rounded-box border border-base-300 bg-base-100 mb-6">
30
+ <table class="table">
31
+ <thead>
32
+ <tr><th scope="col">Name</th><th scope="col">Email</th><th scope="col">Role</th><th scope="col"><span class="sr-only">Actions</span></th></tr>
33
+ </thead>
34
+ <tbody>
35
+ {#each data.editors as editor (editor.email)}
36
+ {@const isSelf = editor.email === data.self}
37
+ <tr>
38
+ <td>{editor.displayName}</td>
39
+ <td>{editor.email}</td>
40
+ <td>
41
+ <span class="badge {editor.role === 'owner' ? 'badge-primary' : 'badge-ghost'}">{editor.role}</span>
42
+ </td>
43
+ <td class="flex justify-end gap-2">
44
+ <form method="POST" action="?/setRole">
45
+ <input type="hidden" name="email" value={editor.email} />
46
+ <input type="hidden" name="role" value={editor.role === 'owner' ? 'editor' : 'owner'} />
47
+ <button type="submit" class="btn btn-ghost btn-xs" disabled={isSelf} aria-label={`Toggle role for ${editor.displayName}`}>
48
+ {editor.role === 'owner' ? 'Make editor' : 'Make owner'}
49
+ </button>
50
+ </form>
51
+ <form method="POST" action="?/remove">
52
+ <input type="hidden" name="email" value={editor.email} />
53
+ <button type="submit" class="btn btn-ghost btn-xs text-error" disabled={isSelf} aria-label={`Remove ${editor.displayName}`}>
54
+ Remove
55
+ </button>
56
+ </form>
57
+ </td>
58
+ </tr>
59
+ {/each}
60
+ </tbody>
61
+ </table>
62
+ </div>
63
+
64
+ <form method="POST" action="?/add" class="rounded-box border border-base-300 bg-base-100 grid gap-3 p-4 sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
65
+ <label class="flex flex-col gap-1">
66
+ <span class="text-sm font-medium">Name</span>
67
+ <input class="input" name="name" aria-label="Name" required />
68
+ </label>
69
+ <label class="flex flex-col gap-1">
70
+ <span class="text-sm font-medium">Email</span>
71
+ <input class="input" type="email" name="email" aria-label="Email" autocomplete="off" required />
72
+ </label>
73
+ <label class="flex flex-col gap-1">
74
+ <span class="text-sm font-medium">Role</span>
75
+ <select class="select" name="role" aria-label="Role">
76
+ <option value="editor">editor</option>
77
+ <option value="owner">owner</option>
78
+ </select>
79
+ </label>
80
+ <button type="submit" class="btn btn-primary">Add editor</button>
81
+ </form>
@@ -0,0 +1,81 @@
1
+ <!--
2
+ @component
3
+ The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over Carta exposing a bindable value
4
+ and a cursor-insert callback. Carta and Shiki are client-only, so the editor mounts after the
5
+ component does; until then the hidden field still carries the value so the form submits correctly.
6
+ Swapping Carta for a bare CodeMirror editor stays a one-file change.
7
+ -->
8
+ <script lang="ts">
9
+ import { onMount } from 'svelte';
10
+
11
+ interface Props {
12
+ /** The markdown source; bindable so the parent reads edits back. */
13
+ value: string;
14
+ /** The hidden field name the value is mirrored to for form submit. */
15
+ name: string;
16
+ /** Carta extensions from the adapter, for the design-accurate preview. */
17
+ plugins?: unknown[];
18
+ /** Receives a `(text) => void` that inserts at the cursor; the palette calls it. */
19
+ registerInsert?: (insert: (text: string) => void) => void;
20
+ }
21
+
22
+ let { value = $bindable(), name, plugins = [], registerInsert }: Props = $props();
23
+
24
+ // Local structural type for the Carta editing surface this seam uses. carta-md re-exports its
25
+ // Svelte components from the package entry, so its `Carta` class is not reachable as a named
26
+ // export under NodeNext; a structural type stays compatible without naming it (the shape
27
+ // legacy/src/lib/editor.ts relied on, verified against carta-md@4.11).
28
+ interface CartaInput {
29
+ getSelection(): { start: number };
30
+ insertAt(position: number, text: string): void;
31
+ update(): boolean;
32
+ }
33
+ interface CartaLike {
34
+ input?: CartaInput;
35
+ }
36
+
37
+ let mounted = $state(false);
38
+ // Carta and the MarkdownEditor component load only in the browser, after mount, so the server
39
+ // bundle never pulls in Carta or Shiki (guarded by the carta-boundary test). The component keeps
40
+ // its real type, so `value` stays bindable; the Carta constructor is reached through a cast
41
+ // because the package entry does not surface the class by name.
42
+ let Editor = $state<(typeof import('carta-md'))['MarkdownEditor'] | null>(null);
43
+ let carta = $state<CartaLike | null>(null);
44
+
45
+ onMount(async () => {
46
+ const mod = await import('carta-md');
47
+ const CartaCtor = (
48
+ mod as unknown as { Carta: new (options: { extensions?: unknown[]; sanitizer: false }) => CartaLike }
49
+ ).Carta;
50
+ const instance = new CartaCtor({
51
+ extensions: plugins,
52
+ // Sanitization is the site adapter's concern; the seam passes raw markdown through.
53
+ sanitizer: false,
54
+ });
55
+ carta = instance;
56
+ Editor = mod.MarkdownEditor;
57
+ // Insert at the current cursor through carta.input once the editor is mounted; fall back to
58
+ // appending while input is not yet populated (the pre-mount textarea phase).
59
+ registerInsert?.((text: string) => {
60
+ const inp = instance.input;
61
+ if (inp) {
62
+ const pos = inp.getSelection().start;
63
+ const prefix = pos > 0 ? '\n\n' : '';
64
+ inp.insertAt(pos, `${prefix}${text}`);
65
+ inp.update();
66
+ } else {
67
+ value = value ? `${value}\n\n${text}` : text;
68
+ }
69
+ });
70
+ mounted = true;
71
+ });
72
+ </script>
73
+
74
+ <input type="hidden" {name} value={value} />
75
+
76
+ {#if mounted && Editor && carta}
77
+ {@const EditorComponent = Editor}
78
+ <EditorComponent carta={carta as never} bind:value theme="default" mode="tabs" />
79
+ {:else}
80
+ <textarea class="textarea min-h-64 w-full font-mono text-sm" bind:value aria-label="Markdown source"></textarea>
81
+ {/if}
@@ -1,18 +1,30 @@
1
+ <!--
2
+ @component
3
+ The navigation tree editor. It edits a flat working copy of the menu (each row carries an
4
+ explicit depth) and posts the whole tree as JSON to the save action. Vertical order comes from
5
+ svelte-sortable-list (mouse, and keyboard with Space to lift, arrows to move, Space to drop);
6
+ depth comes from the Indent and Outdent buttons, capped at the menu's maxDepth. The engine
7
+ validates on save.
8
+ -->
1
9
  <script lang="ts">
2
- // The navigation tree editor (Pass L). Edits a local copy of the menu tree and posts the whole
3
- // tree as JSON to the `save` action. DaisyUI primitives under the Warm Stone admin theme. Drag a
4
- // row up or down to reorder within its level; use Indent/Outdent to nest under the previous
5
- // sibling or promote a level (capped at the menu's maxDepth). The engine validates on save.
6
10
  import { untrack } from 'svelte';
7
- import type { NavLoadData } from '../sveltekit';
8
- import type { NavNode } from '../nav';
11
+ import { SortableList, sortItems } from '@rodrigodagostino/svelte-sortable-list';
12
+ import type { SortableList as SortableListNS } from '@rodrigodagostino/svelte-sortable-list';
13
+ import '@rodrigodagostino/svelte-sortable-list/styles.css';
14
+ import type { NavLoadData } from '../sveltekit/nav-routes.js';
15
+ import type { NavNode } from '../nav/site-config.js';
9
16
 
10
- let { data }: { data: NavLoadData } = $props();
17
+ interface Props {
18
+ /** The nav load's data: the menu meta, the current tree, page options, and flags. */
19
+ data: NavLoadData;
20
+ }
21
+
22
+ let { data }: Props = $props();
11
23
 
12
- // A flat, ordered working model is far simpler to drag-edit than a recursive one: each row
13
- // carries an explicit depth, and the tree is rebuilt from (order + depth) only at submit time.
24
+ // A flat, ordered working model is simpler to reorder than a recursive one: each row carries an
25
+ // explicit depth, and the nested tree is rebuilt from order plus depth only at submit time.
14
26
  interface Row {
15
- id: number;
27
+ id: string;
16
28
  depth: number;
17
29
  label: string;
18
30
  url: string;
@@ -21,14 +33,19 @@
21
33
  let nextId = 1;
22
34
  function flatten(nodes: NavNode[], depth: number, out: Row[]): Row[] {
23
35
  for (const n of nodes) {
24
- out.push({ id: nextId++, depth, label: n.label, url: n.url ?? '' });
36
+ out.push({ id: `row-${nextId++}`, depth, label: n.label, url: n.url ?? '' });
25
37
  if (n.children?.length) flatten(n.children, depth + 1, out);
26
38
  }
27
39
  return out;
28
40
  }
29
41
 
42
+ // untrack here is not for runtime behavior -- $state runs its initializer once regardless.
43
+ // It suppresses the Svelte compiler warning that `data` (a prop) is referenced outside a
44
+ // reactive context. The component is always remounted on save/error (both redirect), so
45
+ // a one-time snapshot of the initial tree is correct.
30
46
  let rows = $state<Row[]>(untrack(() => flatten(data.tree, 0, [])));
31
- const maxDepthIndex = $derived(data.menu.maxDepth - 1); // depth is 0-based here
47
+ // depth is 0-based internally; maxDepth in the config is 1-based (1 = flat, 2 = one nesting level)
48
+ const maxDepthIndex = $derived(data.menu.maxDepth - 1);
32
49
 
33
50
  // Rebuild the nested tree from the flat rows by depth, then serialize for the hidden field.
34
51
  function toTree(list: Row[]): NavNode[] {
@@ -48,9 +65,9 @@
48
65
  const treeJson = $derived(JSON.stringify(toTree(rows)));
49
66
 
50
67
  function addRow() {
51
- rows = [...rows, { id: nextId++, depth: 0, label: 'New item', url: '' }];
68
+ rows = [...rows, { id: `row-${nextId++}`, depth: 0, label: 'New item', url: '' }];
52
69
  }
53
- function removeRow(id: number) {
70
+ function removeRow(id: string) {
54
71
  rows = rows.filter((r) => r.id !== id);
55
72
  }
56
73
  function indent(i: number) {
@@ -63,66 +80,59 @@
63
80
  if (rows[i].depth > 0) rows[i].depth -= 1;
64
81
  }
65
82
 
66
- let dragFrom = $state<number | null>(null);
67
- function onDrop(to: number) {
68
- if (dragFrom === null || dragFrom === to) return;
69
- const next = [...rows];
70
- const [moved] = next.splice(dragFrom, 1);
71
- next.splice(to, 0, moved);
72
- rows = next;
73
- dragFrom = null;
83
+ function handleDragEnd(e: SortableListNS.RootEvents['ondragend']) {
84
+ const { draggedItemIndex, targetItemIndex, isCanceled } = e;
85
+ if (!isCanceled && typeof targetItemIndex === 'number' && draggedItemIndex !== targetItemIndex) {
86
+ rows = sortItems(rows, draggedItemIndex, targetItemIndex);
87
+ }
74
88
  }
75
89
  </script>
76
90
 
77
- <div class="cairn-admin">
78
- <div class="flex items-center justify-between">
79
- <h1 class="text-xl font-semibold">{data.menu.label}</h1>
91
+ <h1 class="mb-4 text-xl font-semibold">{data.menu.label}</h1>
92
+
93
+ {#if data.saved}
94
+ <div role="status" class="alert alert-success mb-4 text-sm">Navigation saved.</div>
95
+ {/if}
96
+ {#if data.error}
97
+ <div role="alert" class="alert alert-error mb-4 text-sm">{data.error}</div>
98
+ {/if}
99
+
100
+ <form method="POST" action="?/save">
101
+ <input type="hidden" name="tree" value={treeJson} />
102
+
103
+ <div class="mb-2">
80
104
  <button type="button" class="btn btn-sm" onclick={addRow}>Add item</button>
81
105
  </div>
82
106
 
83
- {#if data.saved}
84
- <div class="alert alert-success mt-3">Navigation saved.</div>
85
- {/if}
86
- {#if data.error}
87
- <div class="alert alert-error mt-3">{data.error}</div>
88
- {/if}
89
-
90
- <form method="POST" action="?/save" class="mt-4">
91
- <input type="hidden" name="tree" value={treeJson} />
92
- <ul class="menu w-full gap-1">
93
- {#each rows as row, i (row.id)}
94
- <li
95
- draggable="true"
96
- ondragstart={() => (dragFrom = i)}
97
- ondragover={(e) => e.preventDefault()}
98
- ondrop={() => onDrop(i)}
99
- style={`margin-left:${row.depth * 1.5}rem`}
100
- >
101
- <div class="flex items-center gap-2 p-2">
102
- <span class="cursor-grab opacity-40" aria-hidden="true">&#x283F;</span>
103
- <input class="input input-sm input-bordered flex-1" placeholder="Label" bind:value={row.label} />
107
+ <div class="sortable-list-area" style="min-height:2.5rem">
108
+ <SortableList.Root ondragend={handleDragEnd} aria-label="Navigation items">
109
+ {#each rows as row, index (row.id)}
110
+ <SortableList.Item id={row.id} {index} aria-label={`${row.label || 'Untitled'}, level ${row.depth + 1}`}>
111
+ <div class="flex items-center gap-2 p-2" style={`margin-left:${row.depth * 1.5}rem`}>
112
+ <input class="input input-sm flex-1" placeholder="Label" aria-label="Label" bind:value={row.label} />
104
113
  <input
105
- class="input input-sm input-bordered flex-1"
106
- placeholder="/path or https://…"
114
+ class="input input-sm flex-1"
115
+ placeholder="/path or https://example.com"
107
116
  list="cairn-nav-pages"
117
+ aria-label="URL"
108
118
  bind:value={row.url}
109
119
  />
110
- <button type="button" class="btn btn-xs btn-ghost" onclick={() => outdent(i)} aria-label="Outdent">&larr;</button>
111
- <button type="button" class="btn btn-xs btn-ghost" onclick={() => indent(i)} aria-label="Indent">&rarr;</button>
112
- <button type="button" class="btn btn-xs btn-ghost text-error" onclick={() => removeRow(row.id)} aria-label="Remove">&times;</button>
120
+ <button type="button" class="btn btn-xs btn-ghost" onclick={() => outdent(index)} aria-label="Outdent">&larr;</button>
121
+ <button type="button" class="btn btn-xs btn-ghost" onclick={() => indent(index)} aria-label="Indent">&rarr;</button>
122
+ <button type="button" class="btn btn-xs btn-ghost text-error" onclick={() => removeRow(row.id)} aria-label={`Remove ${row.label}`}>&times;</button>
113
123
  </div>
114
- </li>
124
+ </SortableList.Item>
115
125
  {/each}
116
- </ul>
126
+ </SortableList.Root>
127
+ </div>
117
128
 
118
- <datalist id="cairn-nav-pages">
119
- {#each data.pages as p (p.url)}
120
- <option value={p.url}>{p.label}</option>
121
- {/each}
122
- </datalist>
129
+ <datalist id="cairn-nav-pages">
130
+ {#each data.pages as p (p.url)}
131
+ <option value={p.url}>{p.label}</option>
132
+ {/each}
133
+ </datalist>
123
134
 
124
- <div class="mt-4">
125
- <button type="submit" class="btn btn-primary btn-sm">Save navigation</button>
126
- </div>
127
- </form>
128
- </div>
135
+ <div class="mt-4">
136
+ <button type="submit" class="btn btn-primary btn-sm">Save navigation</button>
137
+ </div>
138
+ </form>
@@ -0,0 +1,42 @@
1
+ /* Warm Stone: the cairn admin theme. Self-contained, since DaisyUI v5 reads these vars at point
2
+ of use, so this fully overrides the host's theme with no @plugin and no host build step. */
3
+ [data-theme='cairn-admin'] {
4
+ color-scheme: light;
5
+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
6
+
7
+ --color-base-100: oklch(98.5% 0.004 75);
8
+ --color-base-200: oklch(96% 0.005 75);
9
+ --color-base-300: oklch(92% 0.008 75);
10
+ --color-base-content: oklch(28% 0.012 75);
11
+
12
+ --color-primary: oklch(52% 0.2 293);
13
+ --color-primary-content: oklch(98% 0.012 293);
14
+ --color-secondary: oklch(45% 0.02 75);
15
+ --color-secondary-content: oklch(98% 0.004 75);
16
+ --color-accent: oklch(58% 0.16 300);
17
+ --color-accent-content: oklch(98% 0.012 300);
18
+ --color-neutral: oklch(32% 0.012 75);
19
+ --color-neutral-content: oklch(96% 0.004 75);
20
+
21
+ --color-info: oklch(60% 0.12 240);
22
+ --color-info-content: oklch(98% 0.012 240);
23
+ --color-success: oklch(58% 0.12 150);
24
+ --color-success-content: oklch(98% 0.012 150);
25
+ --color-warning: oklch(75% 0.15 70);
26
+ --color-warning-content: oklch(26% 0.05 70);
27
+ --color-error: oklch(58% 0.2 25);
28
+ --color-error-content: oklch(98% 0.012 25);
29
+
30
+ /* Accessible muted text tones: >= 4.5:1 contrast on base-100/base-200. */
31
+ --color-muted: oklch(48% 0.01 75);
32
+ --color-subtle: oklch(42% 0.01 75);
33
+
34
+ --radius-selector: 0.5rem;
35
+ --radius-field: 0.5rem;
36
+ --radius-box: 0.75rem;
37
+ --size-selector: 0.25rem;
38
+ --size-field: 0.25rem;
39
+ --border: 1px;
40
+ --depth: 1;
41
+ --noise: 0;
42
+ }
@@ -1,10 +1,11 @@
1
- // cairn-cms admin UI shell. Consumers import from 'cairn-cms/components'; each site's
2
- // admin route `.svelte` files are one-line shims around these.
1
+ // Admin Svelte components (Plan 05). The Warm Stone theme ships as a CSS side effect imported
2
+ // by the components that set `data-theme="cairn-admin"`.
3
3
  export { default as AdminLayout } from './AdminLayout.svelte';
4
- export { default as CollectionList } from './CollectionList.svelte';
5
4
  export { default as LoginPage } from './LoginPage.svelte';
6
5
  export { default as ConfirmPage } from './ConfirmPage.svelte';
6
+ export { default as ConceptList } from './ConceptList.svelte';
7
7
  export { default as EditPage } from './EditPage.svelte';
8
- export { default as ManageAdmins } from './ManageAdmins.svelte';
8
+ export { default as ManageEditors } from './ManageEditors.svelte';
9
+ export { default as MarkdownEditor } from './MarkdownEditor.svelte';
9
10
  export { default as ComponentPalette } from './ComponentPalette.svelte';
10
11
  export { default as NavTree } from './NavTree.svelte';
@@ -0,0 +1,39 @@
1
+ // cairn-cms: composition aggregation (seam 2). One place folds the adapter and any
2
+ // extensions into the runtime the engine serves from. A future `CairnExtension` folds in
3
+ // the same way and contributes the same kinds of things: nav entries, route logic,
4
+ // concepts, components, field types, and save hooks. Shaped now so the extension contract
5
+ // is additive later.
6
+ import type { AdminPanel, CairnAdapter, CairnExtension, CairnRuntime, ConceptConfig, FieldTypeDef } from './types.js';
7
+ import { normalizeConcepts } from './concepts.js';
8
+
9
+ /**
10
+ * Fold an adapter and any extensions into the composed runtime (seam 2). Extension concepts
11
+ * merge after the adapter's. The asset slot (seam 4) passes through untouched.
12
+ */
13
+ export function composeRuntime(
14
+ adapter: CairnAdapter,
15
+ extensions: CairnExtension[] = [],
16
+ ): CairnRuntime {
17
+ const content: Record<string, ConceptConfig | undefined> = { ...adapter.content };
18
+ const adminPanels: AdminPanel[] = [];
19
+ const fieldTypes: FieldTypeDef[] = [];
20
+ for (const extension of extensions) {
21
+ // An extension adds concepts; a key that collides with the adapter is last-write-wins.
22
+ // Reserved seam, unused today, so the collision policy is deliberately left simple.
23
+ if (extension.content) Object.assign(content, extension.content);
24
+ if (extension.adminPanels) adminPanels.push(...extension.adminPanels);
25
+ if (extension.fieldTypes) fieldTypes.push(...extension.fieldTypes);
26
+ }
27
+ return {
28
+ siteName: adapter.siteName,
29
+ concepts: normalizeConcepts(content),
30
+ backend: adapter.backend,
31
+ sender: adapter.sender,
32
+ renderPreview: adapter.renderPreview,
33
+ registry: adapter.registry,
34
+ navMenu: adapter.navMenu,
35
+ assets: adapter.assets,
36
+ adminPanels,
37
+ fieldTypes,
38
+ };
39
+ }
@@ -0,0 +1,57 @@
1
+ // cairn-cms: concept normalization (seam 1). The adapter declares concepts as
2
+ // `content: { posts?, pages? }`; this turns each declared key into a uniform descriptor
3
+ // (id, label, directory, concept-fixed routing, fields, validator) the admin reads. A
4
+ // future Fragments concept attaches by adding one key under `content` and one routing
5
+ // entry, with no reshape here.
6
+ import type { ConceptConfig, ConceptDescriptor, RoutingRule } from './types.js';
7
+
8
+ /**
9
+ * Concept-fixed routing, keyed by concept id (spec §7.2). Posts are dated feed entries;
10
+ * pages are plain navigable structure. Not in adapter config. A future Fragments adds one
11
+ * entry here and one key under `content`.
12
+ */
13
+ export const CONCEPT_ROUTING: Readonly<Record<string, RoutingRule>> = {
14
+ posts: { routable: true, dated: true, inFeeds: true },
15
+ pages: { routable: true, dated: false, inFeeds: false },
16
+ };
17
+
18
+ /** Routing for a concept with no table entry: a plain, non-feed, routable page. */
19
+ const DEFAULT_ROUTING: RoutingRule = { routable: true, dated: false, inFeeds: false };
20
+
21
+ /** Title-case a concept id for the default sidebar label, e.g. "posts" to "Posts". */
22
+ function defaultLabel(id: string): string {
23
+ return id.charAt(0).toUpperCase() + id.slice(1);
24
+ }
25
+
26
+ /**
27
+ * Normalize an adapter's declared concepts into uniform descriptors (seam 1). Each declared
28
+ * key under `content` becomes one descriptor; an undeclared (`undefined`) concept is
29
+ * skipped. `routing` is injectable so a contract test can prove a new concept attaches
30
+ * additively; production passes the default `CONCEPT_ROUTING`.
31
+ */
32
+ export function normalizeConcepts(
33
+ content: Record<string, ConceptConfig | undefined>,
34
+ routing: Readonly<Record<string, RoutingRule>> = CONCEPT_ROUTING,
35
+ ): ConceptDescriptor[] {
36
+ const descriptors: ConceptDescriptor[] = [];
37
+ for (const [id, config] of Object.entries(content)) {
38
+ if (!config) continue;
39
+ descriptors.push({
40
+ id,
41
+ label: config.label ?? defaultLabel(id),
42
+ dir: config.dir,
43
+ routing: routing[id] ?? DEFAULT_ROUTING,
44
+ fields: config.fields,
45
+ validate: config.validate,
46
+ });
47
+ }
48
+ return descriptors;
49
+ }
50
+
51
+ /** Look up a normalized concept by id, or undefined when the site does not enable it. */
52
+ export function findConcept(
53
+ concepts: ConceptDescriptor[],
54
+ id: string,
55
+ ): ConceptDescriptor | undefined {
56
+ return concepts.find((concept) => concept.id === id);
57
+ }
@@ -0,0 +1,71 @@
1
+ // cairn-cms: frontmatter form decoding and on-disk serialization. `frontmatterFromForm`
2
+ // is the form-to-data half of the edit loop; `serializeMarkdown`/`parseMarkdown` are the
3
+ // on-disk write/read pair. Kept as one seam so a site owns its serialization contract
4
+ // (quoting, key order) without the save endpoint reaching for gray-matter directly.
5
+ import matter from 'gray-matter';
6
+ import type { FrontmatterField } from './types.js';
7
+
8
+ /** Decode submitted form data into raw frontmatter, one rule per field type. */
9
+ export function frontmatterFromForm(
10
+ fields: FrontmatterField[],
11
+ form: FormData,
12
+ ): Record<string, unknown> {
13
+ const data: Record<string, unknown> = {};
14
+ for (const field of fields) {
15
+ switch (field.type) {
16
+ case 'boolean':
17
+ data[field.name] = form.get(field.name) === 'on';
18
+ break;
19
+ case 'tags':
20
+ data[field.name] = form.getAll(field.name).map(String);
21
+ break;
22
+ case 'freetags':
23
+ // One comma-separated input to trimmed, de-duplicated, non-empty tags.
24
+ data[field.name] = [
25
+ ...new Set(
26
+ String(form.get(field.name) ?? '')
27
+ .split(',')
28
+ .map((tag) => tag.trim())
29
+ .filter(Boolean),
30
+ ),
31
+ ];
32
+ break;
33
+ default:
34
+ // FormData.get returns null for an absent field; normalize to an empty string so
35
+ // a caller reading a text value never gets null.
36
+ data[field.name] = form.get(field.name) ?? '';
37
+ }
38
+ }
39
+ return data;
40
+ }
41
+
42
+ /**
43
+ * Coerce a frontmatter date value to the `YYYY-MM-DD` an `<input type="date">` wants.
44
+ * gray-matter parses an unquoted YAML date into a JS Date, so a string-only read would
45
+ * leave the input empty and drop the date on save. A parsed YAML date is UTC midnight, so
46
+ * slicing the ISO string avoids a local-timezone shift.
47
+ */
48
+ export function dateInputValue(value: unknown): string {
49
+ if (value instanceof Date) {
50
+ return Number.isNaN(value.getTime()) ? '' : value.toISOString().slice(0, 10);
51
+ }
52
+ if (typeof value === 'string') {
53
+ const match = value.match(/^\d{4}-\d{2}-\d{2}/);
54
+ return match ? match[0] : '';
55
+ }
56
+ return '';
57
+ }
58
+
59
+ /** Reassemble a markdown file from frontmatter and body for committing. */
60
+ export function serializeMarkdown(frontmatter: object, body: string): string {
61
+ return matter.stringify(body, frontmatter);
62
+ }
63
+
64
+ /** Parse a markdown file into its frontmatter and body: the read-side inverse of serialize. */
65
+ export function parseMarkdown(source: string): {
66
+ frontmatter: Record<string, unknown>;
67
+ body: string;
68
+ } {
69
+ const parsed = matter(source);
70
+ return { frontmatter: parsed.data, body: parsed.content };
71
+ }
@@ -0,0 +1,38 @@
1
+ // cairn-cms: filename-based content ids (spec §7.2). An entry's id is its markdown filename
2
+ // without `.md`, so there is no slug codec. `slugify` derives a filename-safe stem from a
3
+ // title for the create-entry form.
4
+
5
+ /** Lowercase alphanumerics with single internal hyphens: the on-disk filename stem rule. */
6
+ const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+
8
+ /** True when `id` is a valid filename stem: lowercase, no slashes, no leading or trailing hyphen. */
9
+ export function isValidId(id: string): boolean {
10
+ return ID_RE.test(id);
11
+ }
12
+
13
+ /**
14
+ * A content entry's id from its filename: the basename without the `.md` suffix. Pass a
15
+ * basename, not a path; the caller strips any directory prefix first (Plan 03's Git Trees
16
+ * listing yields basenames directly).
17
+ */
18
+ export function idFromFilename(filename: string): string {
19
+ return filename.replace(/\.md$/, '');
20
+ }
21
+
22
+ /** The on-disk filename for an id: the id plus `.md`. */
23
+ export function filenameFromId(id: string): string {
24
+ return `${id}.md`;
25
+ }
26
+
27
+ /**
28
+ * Lowercase a title into a filename-safe slug stem. Apostrophes are dropped so "Geoff's"
29
+ * becomes "geoffs" (no spurious hyphen). All other non-alphanumeric runs collapse to a
30
+ * single hyphen; leading and trailing hyphens are trimmed.
31
+ */
32
+ export function slugify(title: string): string {
33
+ return title
34
+ .toLowerCase()
35
+ .replace(/'/g, '')
36
+ .replace(/[^a-z0-9]+/g, '-')
37
+ .replace(/^-+|-+$/g, '');
38
+ }