@glw907/cairn-cms 0.5.0 → 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 (216) 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 -108
  11. package/dist/components/AdminLayout.svelte.d.ts +14 -9
  12. package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
  13. package/dist/components/ComponentPalette.svelte +50 -0
  14. package/dist/components/ComponentPalette.svelte.d.ts +16 -0
  15. package/dist/components/ComponentPalette.svelte.d.ts.map +1 -0
  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 +160 -103
  23. package/dist/components/EditPage.svelte.d.ts +17 -7
  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 +138 -0
  35. package/dist/components/NavTree.svelte.d.ts +17 -0
  36. package/dist/components/NavTree.svelte.d.ts.map +1 -0
  37. package/dist/components/cairn-admin.css +42 -0
  38. package/dist/components/index.d.ts +5 -2
  39. package/dist/components/index.d.ts.map +1 -1
  40. package/dist/components/index.js +7 -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 -6
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +21 -8
  80. package/dist/nav/site-config.d.ts +50 -0
  81. package/dist/nav/site-config.d.ts.map +1 -0
  82. package/dist/nav/site-config.js +100 -0
  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 -83
  117. package/dist/sveltekit/index.d.ts.map +1 -1
  118. package/dist/sveltekit/index.js +8 -149
  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 +38 -58
  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 -108
  130. package/src/lib/components/ComponentPalette.svelte +50 -0
  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 +160 -103
  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 +138 -0
  138. package/src/lib/components/cairn-admin.css +42 -0
  139. package/src/lib/components/index.ts +7 -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 -8
  153. package/src/lib/nav/site-config.ts +124 -0
  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 -235
  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 -69
  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/config.d.ts +0 -2097
  176. package/dist/auth/config.d.ts.map +0 -1
  177. package/dist/auth/config.js +0 -78
  178. package/dist/auth/guard.d.ts +0 -34
  179. package/dist/auth/guard.d.ts.map +0 -1
  180. package/dist/auth/guard.js +0 -47
  181. package/dist/auth/index.d.ts +0 -4
  182. package/dist/auth/index.d.ts.map +0 -1
  183. package/dist/auth/index.js +0 -6
  184. package/dist/auth/schema.d.ts +0 -750
  185. package/dist/auth/schema.d.ts.map +0 -1
  186. package/dist/auth/schema.js +0 -93
  187. package/dist/carta.d.ts +0 -39
  188. package/dist/carta.d.ts.map +0 -1
  189. package/dist/carta.js +0 -30
  190. package/dist/components/AdminList.svelte +0 -33
  191. package/dist/components/AdminList.svelte.d.ts +0 -10
  192. package/dist/components/AdminList.svelte.d.ts.map +0 -1
  193. package/dist/components/ManageAdmins.svelte +0 -84
  194. package/dist/components/ManageAdmins.svelte.d.ts +0 -10
  195. package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
  196. package/dist/content.d.ts +0 -3
  197. package/dist/content.d.ts.map +0 -1
  198. package/dist/content.js +0 -10
  199. package/dist/github.d.ts +0 -72
  200. package/dist/github.d.ts.map +0 -1
  201. package/dist/github.js +0 -171
  202. package/dist/utils.d.ts +0 -3
  203. package/dist/utils.d.ts.map +0 -1
  204. package/dist/utils.js +0 -11
  205. package/src/lib/adapter.ts +0 -119
  206. package/src/lib/auth/admins.ts +0 -106
  207. package/src/lib/auth/config.ts +0 -108
  208. package/src/lib/auth/guard.ts +0 -60
  209. package/src/lib/auth/index.ts +0 -6
  210. package/src/lib/auth/schema.ts +0 -112
  211. package/src/lib/carta.ts +0 -59
  212. package/src/lib/components/AdminList.svelte +0 -33
  213. package/src/lib/components/ManageAdmins.svelte +0 -84
  214. package/src/lib/content.ts +0 -11
  215. package/src/lib/github.ts +0 -220
  216. package/src/lib/utils.ts +0 -12
@@ -0,0 +1,138 @@
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
+ -->
9
+ <script lang="ts">
10
+ import { untrack } from 'svelte';
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';
16
+
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();
23
+
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.
26
+ interface Row {
27
+ id: string;
28
+ depth: number;
29
+ label: string;
30
+ url: string;
31
+ }
32
+
33
+ let nextId = 1;
34
+ function flatten(nodes: NavNode[], depth: number, out: Row[]): Row[] {
35
+ for (const n of nodes) {
36
+ out.push({ id: `row-${nextId++}`, depth, label: n.label, url: n.url ?? '' });
37
+ if (n.children?.length) flatten(n.children, depth + 1, out);
38
+ }
39
+ return out;
40
+ }
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.
46
+ let rows = $state<Row[]>(untrack(() => flatten(data.tree, 0, [])));
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);
49
+
50
+ // Rebuild the nested tree from the flat rows by depth, then serialize for the hidden field.
51
+ function toTree(list: Row[]): NavNode[] {
52
+ const root: NavNode[] = [];
53
+ const stack: { depth: number; node: NavNode }[] = [];
54
+ for (const r of list) {
55
+ const node: NavNode = { label: r.label.trim() };
56
+ if (r.url.trim()) node.url = r.url.trim();
57
+ while (stack.length && stack[stack.length - 1].depth >= r.depth) stack.pop();
58
+ if (stack.length) (stack[stack.length - 1].node.children ??= []).push(node);
59
+ else root.push(node);
60
+ stack.push({ depth: r.depth, node });
61
+ }
62
+ return root;
63
+ }
64
+
65
+ const treeJson = $derived(JSON.stringify(toTree(rows)));
66
+
67
+ function addRow() {
68
+ rows = [...rows, { id: `row-${nextId++}`, depth: 0, label: 'New item', url: '' }];
69
+ }
70
+ function removeRow(id: string) {
71
+ rows = rows.filter((r) => r.id !== id);
72
+ }
73
+ function indent(i: number) {
74
+ // A row may nest at most one level deeper than the row above it, and never past the cap.
75
+ if (i === 0) return;
76
+ const ceiling = Math.min(rows[i - 1].depth + 1, maxDepthIndex);
77
+ if (rows[i].depth < ceiling) rows[i].depth += 1;
78
+ }
79
+ function outdent(i: number) {
80
+ if (rows[i].depth > 0) rows[i].depth -= 1;
81
+ }
82
+
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
+ }
88
+ }
89
+ </script>
90
+
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">
104
+ <button type="button" class="btn btn-sm" onclick={addRow}>Add item</button>
105
+ </div>
106
+
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} />
113
+ <input
114
+ class="input input-sm flex-1"
115
+ placeholder="/path or https://example.com"
116
+ list="cairn-nav-pages"
117
+ aria-label="URL"
118
+ bind:value={row.url}
119
+ />
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>
123
+ </div>
124
+ </SortableList.Item>
125
+ {/each}
126
+ </SortableList.Root>
127
+ </div>
128
+
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>
134
+
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,8 +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 AdminList } from './AdminList.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';
10
+ export { default as ComponentPalette } from './ComponentPalette.svelte';
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
+ }
@@ -0,0 +1,235 @@
1
+ // cairn-cms: the adapter contract a site implements, and the engine-internal descriptors
2
+ // the contract normalizes into.
3
+ //
4
+ // The adapter is the single seam the engine consumes (spec §8). A site supplies a
5
+ // `CairnAdapter` at `src/lib/cairn.config.ts` declaring its backend repo, the content
6
+ // concepts it enables, its magic-link sender, and a design-accurate `renderPreview`. The
7
+ // engine never hard-codes a concept, directory, or field; it reads them here. Field
8
+ // descriptors are plain data so a `load` function can hand them across the server-to-client
9
+ // boundary to the editor form.
10
+ import type { ComponentRegistry } from '../render/registry.js';
11
+
12
+ /** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
13
+ interface FieldBase {
14
+ /** Frontmatter key and form input name. */
15
+ name: string;
16
+ /** Form label. */
17
+ label: string;
18
+ /** A required field fails validation when empty (spec §7.4). */
19
+ required?: boolean;
20
+ }
21
+
22
+ /** A single-line text input. */
23
+ export interface TextField extends FieldBase {
24
+ type: 'text';
25
+ }
26
+ /** A multi-line text input. */
27
+ export interface TextareaField extends FieldBase {
28
+ type: 'textarea';
29
+ /** Visible rows; the editor picks a default when omitted. */
30
+ rows?: number;
31
+ }
32
+ /** A `YYYY-MM-DD` date input. */
33
+ export interface DateField extends FieldBase {
34
+ type: 'date';
35
+ }
36
+ /** A checkbox; absent means false. */
37
+ export interface BooleanField extends FieldBase {
38
+ type: 'boolean';
39
+ }
40
+ /** A closed-vocabulary tag set, rendered as checkboxes (ecnordic). */
41
+ export interface TagsField extends FieldBase {
42
+ type: 'tags';
43
+ /** The controlled vocabulary. */
44
+ options: readonly string[];
45
+ }
46
+ /** Free-form tags, edited as one comma-separated input (907). */
47
+ export interface FreeTagsField extends FieldBase {
48
+ type: 'freetags';
49
+ placeholder?: string;
50
+ }
51
+
52
+ /**
53
+ * The discriminated union the per-concept frontmatter form is generated from. Adding a
54
+ * field type is one variant here plus one decode arm in `frontmatterFromForm` and one in
55
+ * `validateFields`.
56
+ */
57
+ export type FrontmatterField =
58
+ | TextField
59
+ | TextareaField
60
+ | DateField
61
+ | BooleanField
62
+ | TagsField
63
+ | FreeTagsField;
64
+
65
+ /**
66
+ * A validator's verdict. On success it carries the normalized frontmatter to commit; on
67
+ * failure it carries field-keyed error messages (the empty key is a form-level error).
68
+ * Invalid input bounces to the form and never reaches git (spec §7.4).
69
+ */
70
+ export type ValidationResult =
71
+ | { ok: true; data: Record<string, unknown> }
72
+ | { ok: false; errors: Record<string, string> };
73
+
74
+ /**
75
+ * Per-site configuration for one content concept (spec §8). Concept-fixed behavior such as
76
+ * routability is not here; it lives in the engine's routing table (`CONCEPT_ROUTING`).
77
+ */
78
+ export interface ConceptConfig {
79
+ /** Repo-relative content directory, e.g. "src/content/posts". */
80
+ dir: string;
81
+ /** Sidebar label; defaults from the concept id when omitted. */
82
+ label?: string;
83
+ /** Drives the per-concept frontmatter form, in order. */
84
+ fields: FrontmatterField[];
85
+ /** Validate submitted frontmatter before any commit. */
86
+ validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
87
+ }
88
+
89
+ /** The GitHub App backend a site reads from and commits to (spec §8). Plain data the GitHub engine (Plan 03) consumes. */
90
+ export interface BackendConfig {
91
+ owner: string;
92
+ repo: string;
93
+ /** Commit target, e.g. "main". */
94
+ branch: string;
95
+ appId: string;
96
+ installationId: string;
97
+ }
98
+
99
+ /** Magic-link sender identity for Cloudflare Email Sending. */
100
+ export interface SenderConfig {
101
+ from: string;
102
+ replyTo?: string;
103
+ }
104
+
105
+ /** A git-committed YAML menu this site's nav editor manages (Plan 06). */
106
+ export interface NavMenuConfig {
107
+ /** Repo-relative path to the site-config YAML, e.g. "src/lib/site.config.yaml". */
108
+ configPath: string;
109
+ /** Key within the file's menus map, e.g. "primary". */
110
+ menuName: string;
111
+ /** Sidebar label for the menu. */
112
+ label: string;
113
+ /** Max nesting depth allowed in the editor; defaults to 2. */
114
+ maxDepth?: number;
115
+ }
116
+
117
+ /** Reserved asset slot (seam 4). Typed and unused in the rebuild; R7/R9 read it later with no contract change. */
118
+ export interface AssetConfig {
119
+ /** Repo-relative asset roots, e.g. ["static/images"]. */
120
+ roots: string[];
121
+ /** Public URL base, e.g. "/images". */
122
+ publicBase: string;
123
+ }
124
+
125
+ /** The single seam the engine consumes. A site implements this at `src/lib/cairn.config.ts`. */
126
+ export interface CairnAdapter {
127
+ siteName: string;
128
+ /**
129
+ * Which content concepts this site enables. A future `fragments?` key attaches here with
130
+ * no reshape of the contract (seam 1). A site never has two of the same concept.
131
+ */
132
+ content: {
133
+ posts?: ConceptConfig;
134
+ pages?: ConceptConfig;
135
+ };
136
+ backend: BackendConfig;
137
+ sender: SenderConfig;
138
+ /** Design-accurate preview: the same render pipeline the site ships. */
139
+ renderPreview(md: string): string | Promise<string>;
140
+ /** Directive component registry; the renderer and the future palette derive from it (seam 3). */
141
+ registry?: ComponentRegistry;
142
+ navMenu?: NavMenuConfig;
143
+ assets?: AssetConfig;
144
+ }
145
+
146
+ /**
147
+ * Concept-fixed routing for a normalized concept (spec §7.2). Posts are dated feed entries;
148
+ * pages are plain navigable structure. Not in adapter config.
149
+ */
150
+ export interface RoutingRule {
151
+ /** Routable as a standalone URL. A future Fragments concept is embedded, not routable. */
152
+ routable: boolean;
153
+ /** Carries a date (posts). */
154
+ dated: boolean;
155
+ /** Appears in feeds and the sitemap (posts). */
156
+ inFeeds: boolean;
157
+ }
158
+
159
+ /**
160
+ * The engine-internal, uniform view of one concept after normalization (seam 1). The admin
161
+ * nav, the list views, and the editor all read this, never the raw config.
162
+ */
163
+ export interface ConceptDescriptor {
164
+ /** Concept id, the key under `content`, e.g. "posts". */
165
+ id: string;
166
+ label: string;
167
+ dir: string;
168
+ routing: RoutingRule;
169
+ fields: FrontmatterField[];
170
+ validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
171
+ }
172
+
173
+ /**
174
+ * A site-defined admin screen contributed by an extension (Mode 2). It gains a sidebar entry, the
175
+ * `/admin` guard, and the session, and may commit through the same GitHub pipeline. The dispatch
176
+ * route is built in Plan 09; the `load`/`actions`/`component` members are typed loosely here and
177
+ * tightened when the machinery lands.
178
+ */
179
+ export interface AdminPanel {
180
+ /** Routes under `/admin/<id>`; also the sidebar key. */
181
+ id: string;
182
+ /** Sidebar label. */
183
+ label: string;
184
+ /** Owner-gated, like editor management. */
185
+ owner?: boolean;
186
+ /** Server load, behind the guard. Typed in Plan 09. */
187
+ load?: (event: unknown) => unknown;
188
+ /** Named form actions, which may use the commit pipeline. Typed in Plan 09. */
189
+ actions?: Record<string, (event: unknown) => Promise<unknown>>;
190
+ /** The panel UI, rendered inside the admin shell. Typed as a component in Plan 09. */
191
+ component: unknown;
192
+ }
193
+
194
+ /**
195
+ * A custom frontmatter field type contributed by an extension (Mode 2): a renderer plus a validator
196
+ * dispatched alongside the built-in field union. The renderer and validator are typed in Plan 09
197
+ * when the form dispatch becomes a registry; the `type` key reserves the discriminator now.
198
+ */
199
+ export interface FieldTypeDef {
200
+ /** The field-type discriminator, e.g. "color". */
201
+ type: string;
202
+ }
203
+
204
+ /**
205
+ * A future build-time extension (seam 2). It folds in the same way the adapter does and
206
+ * contributes the same kinds of things. Reserved and unused in the rebuild; the shape is
207
+ * fixed now so the extension contract is additive later.
208
+ */
209
+ export interface CairnExtension {
210
+ /** Additional concepts, merged after the adapter's. */
211
+ content?: Record<string, ConceptConfig>;
212
+ /** Site-defined admin panels (Mode 2). Carried onto the runtime now; dispatched in Plan 09. */
213
+ adminPanels?: AdminPanel[];
214
+ /** Custom field types (Mode 2). Carried onto the runtime now; dispatched in Plan 09. */
215
+ fieldTypes?: FieldTypeDef[];
216
+ }
217
+
218
+ /**
219
+ * The composed runtime the engine serves from (seam 2 output). The single aggregation point
220
+ * (`composeRuntime`) folds the adapter and any extensions into this shape.
221
+ */
222
+ export interface CairnRuntime {
223
+ siteName: string;
224
+ concepts: ConceptDescriptor[];
225
+ backend: BackendConfig;
226
+ sender: SenderConfig;
227
+ renderPreview(md: string): string | Promise<string>;
228
+ registry?: ComponentRegistry;
229
+ navMenu?: NavMenuConfig;
230
+ assets?: AssetConfig;
231
+ /** Admin panels contributed by extensions (Mode 2). Empty until Plan 09 wires the dispatch route. */
232
+ adminPanels?: AdminPanel[];
233
+ /** Field types contributed by extensions (Mode 2). Empty until Plan 09 wires the form dispatch. */
234
+ fieldTypes?: FieldTypeDef[];
235
+ }