@glw907/cairn-cms 0.68.0 → 0.76.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 (177) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  4. package/dist/components/ComponentForm.svelte +44 -27
  5. package/dist/components/ComponentInsertDialog.svelte +5 -5
  6. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  7. package/dist/components/EditPage.svelte +29 -107
  8. package/dist/components/EditPage.svelte.d.ts +2 -7
  9. package/dist/components/EntryPicker.svelte +117 -0
  10. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  11. package/dist/components/FieldInput.svelte +218 -0
  12. package/dist/components/FieldInput.svelte.d.ts +51 -0
  13. package/dist/components/IconPicker.svelte +2 -2
  14. package/dist/components/IconPicker.svelte.d.ts +2 -0
  15. package/dist/components/LinkPicker.svelte +8 -75
  16. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  17. package/dist/components/MediaHeroField.svelte +8 -5
  18. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  19. package/dist/components/ObjectGroupField.svelte +54 -0
  20. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  21. package/dist/components/ReferenceField.svelte +94 -0
  22. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  23. package/dist/components/RepeatableField.svelte +221 -0
  24. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  25. package/dist/components/cairn-admin.css +4 -0
  26. package/dist/components/preview-doc.js +5 -1
  27. package/dist/components/tidy-validate.js +1 -1
  28. package/dist/content/adapter.js +18 -0
  29. package/dist/content/advisories.d.ts +2 -2
  30. package/dist/content/advisories.js +3 -5
  31. package/dist/content/compose.d.ts +7 -6
  32. package/dist/content/compose.js +26 -20
  33. package/dist/content/concepts.d.ts +21 -15
  34. package/dist/content/concepts.js +55 -32
  35. package/dist/content/field-rules.js +3 -4
  36. package/dist/content/fields.d.ts +49 -1
  37. package/dist/content/fields.js +11 -0
  38. package/dist/content/fieldset.d.ts +31 -10
  39. package/dist/content/fieldset.js +262 -109
  40. package/dist/content/frontmatter-region.d.ts +38 -0
  41. package/dist/content/frontmatter-region.js +75 -0
  42. package/dist/content/frontmatter.d.ts +35 -2
  43. package/dist/content/frontmatter.js +232 -11
  44. package/dist/content/manifest.d.ts +34 -0
  45. package/dist/content/manifest.js +80 -4
  46. package/dist/content/media-refs.d.ts +2 -2
  47. package/dist/content/media-rewrite.js +1 -69
  48. package/dist/content/reference-index.d.ts +56 -0
  49. package/dist/content/reference-index.js +95 -0
  50. package/dist/content/references.d.ts +40 -0
  51. package/dist/content/references.js +0 -0
  52. package/dist/content/standard-schema.d.ts +30 -0
  53. package/dist/content/standard-schema.js +4 -0
  54. package/dist/content/types.d.ts +127 -178
  55. package/dist/delivery/data.d.ts +2 -2
  56. package/dist/delivery/data.js +1 -1
  57. package/dist/delivery/public-routes.d.ts +2 -5
  58. package/dist/delivery/public-routes.js +15 -1
  59. package/dist/delivery/site-descriptors.d.ts +5 -1
  60. package/dist/delivery/site-descriptors.js +8 -3
  61. package/dist/delivery/site-indexes.d.ts +2 -2
  62. package/dist/delivery/site-resolver.d.ts +25 -0
  63. package/dist/delivery/site-resolver.js +49 -0
  64. package/dist/doctor/checks-local.js +6 -11
  65. package/dist/github/backend.d.ts +83 -0
  66. package/dist/github/backend.js +76 -0
  67. package/dist/github/credentials.d.ts +11 -5
  68. package/dist/github/credentials.js +3 -3
  69. package/dist/github/repo.d.ts +8 -19
  70. package/dist/github/repo.js +69 -80
  71. package/dist/github/types.d.ts +1 -1
  72. package/dist/github/types.js +4 -4
  73. package/dist/index.d.ts +16 -12
  74. package/dist/index.js +7 -8
  75. package/dist/islands/index.d.ts +12 -0
  76. package/dist/islands/index.js +83 -0
  77. package/dist/islands/types.d.ts +7 -0
  78. package/dist/islands/types.js +1 -0
  79. package/dist/media/rewrite-plan.d.ts +2 -3
  80. package/dist/media/rewrite-plan.js +2 -3
  81. package/dist/media/usage.d.ts +2 -2
  82. package/dist/media/usage.js +3 -5
  83. package/dist/nav/site-config.d.ts +0 -6
  84. package/dist/nav/site-config.js +6 -4
  85. package/dist/render/component-grammar.js +11 -11
  86. package/dist/render/component-reference.js +5 -3
  87. package/dist/render/component-validate.d.ts +4 -1
  88. package/dist/render/component-validate.js +10 -35
  89. package/dist/render/pipeline.d.ts +0 -6
  90. package/dist/render/pipeline.js +1 -1
  91. package/dist/render/registry.d.ts +34 -34
  92. package/dist/render/registry.js +26 -5
  93. package/dist/render/rehype-dispatch.d.ts +4 -4
  94. package/dist/render/rehype-dispatch.js +36 -11
  95. package/dist/render/remark-directives.js +4 -5
  96. package/dist/render/sanitize-schema.js +1 -1
  97. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  98. package/dist/sveltekit/cairn-admin.js +3 -4
  99. package/dist/sveltekit/content-routes.d.ts +10 -8
  100. package/dist/sveltekit/content-routes.js +269 -181
  101. package/dist/sveltekit/health.d.ts +7 -3
  102. package/dist/sveltekit/health.js +9 -3
  103. package/dist/sveltekit/index.d.ts +1 -1
  104. package/dist/sveltekit/nav-routes.d.ts +6 -5
  105. package/dist/sveltekit/nav-routes.js +22 -20
  106. package/dist/sveltekit/types.d.ts +2 -0
  107. package/dist/vite/index.d.ts +3 -3
  108. package/dist/vite/index.js +17 -8
  109. package/package.json +5 -1
  110. package/src/lib/ambient.ts +7 -0
  111. package/src/lib/components/CairnAdmin.svelte +2 -6
  112. package/src/lib/components/ComponentForm.svelte +48 -27
  113. package/src/lib/components/ComponentInsertDialog.svelte +9 -8
  114. package/src/lib/components/EditPage.svelte +43 -119
  115. package/src/lib/components/EntryPicker.svelte +154 -0
  116. package/src/lib/components/FieldInput.svelte +262 -0
  117. package/src/lib/components/IconPicker.svelte +4 -2
  118. package/src/lib/components/LinkPicker.svelte +10 -81
  119. package/src/lib/components/MediaHeroField.svelte +12 -5
  120. package/src/lib/components/ObjectGroupField.svelte +97 -0
  121. package/src/lib/components/ReferenceField.svelte +126 -0
  122. package/src/lib/components/RepeatableField.svelte +310 -0
  123. package/src/lib/components/preview-doc.ts +5 -1
  124. package/src/lib/components/tidy-validate.ts +1 -1
  125. package/src/lib/content/adapter.ts +21 -0
  126. package/src/lib/content/advisories.ts +4 -7
  127. package/src/lib/content/compose.ts +30 -23
  128. package/src/lib/content/concepts.ts +68 -40
  129. package/src/lib/content/field-rules.ts +3 -4
  130. package/src/lib/content/fields.ts +52 -1
  131. package/src/lib/content/fieldset.ts +291 -128
  132. package/src/lib/content/frontmatter-region.ts +90 -0
  133. package/src/lib/content/frontmatter.ts +231 -15
  134. package/src/lib/content/manifest.ts +101 -4
  135. package/src/lib/content/media-refs.ts +2 -2
  136. package/src/lib/content/media-rewrite.ts +7 -80
  137. package/src/lib/content/reference-index.ts +159 -0
  138. package/src/lib/content/references.ts +0 -0
  139. package/src/lib/content/standard-schema.ts +25 -0
  140. package/src/lib/content/types.ts +128 -195
  141. package/src/lib/delivery/data.ts +2 -2
  142. package/src/lib/delivery/public-routes.ts +17 -3
  143. package/src/lib/delivery/site-descriptors.ts +8 -3
  144. package/src/lib/delivery/site-indexes.ts +2 -2
  145. package/src/lib/delivery/site-resolver.ts +64 -0
  146. package/src/lib/doctor/checks-local.ts +6 -14
  147. package/src/lib/github/backend.ts +161 -0
  148. package/src/lib/github/credentials.ts +10 -7
  149. package/src/lib/github/repo.ts +79 -83
  150. package/src/lib/github/types.ts +5 -5
  151. package/src/lib/index.ts +38 -23
  152. package/src/lib/islands/index.ts +84 -0
  153. package/src/lib/islands/types.ts +11 -0
  154. package/src/lib/media/rewrite-plan.ts +4 -6
  155. package/src/lib/media/usage.ts +4 -7
  156. package/src/lib/nav/site-config.ts +8 -9
  157. package/src/lib/render/component-grammar.ts +10 -10
  158. package/src/lib/render/component-reference.ts +4 -3
  159. package/src/lib/render/component-validate.ts +10 -35
  160. package/src/lib/render/pipeline.ts +1 -7
  161. package/src/lib/render/registry.ts +58 -39
  162. package/src/lib/render/rehype-dispatch.ts +45 -10
  163. package/src/lib/render/remark-directives.ts +4 -5
  164. package/src/lib/render/sanitize-schema.ts +1 -1
  165. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  166. package/src/lib/sveltekit/content-routes.ts +330 -221
  167. package/src/lib/sveltekit/health.ts +13 -6
  168. package/src/lib/sveltekit/index.ts +2 -2
  169. package/src/lib/sveltekit/nav-routes.ts +33 -29
  170. package/src/lib/sveltekit/types.ts +5 -1
  171. package/src/lib/vite/index.ts +20 -11
  172. package/dist/content/schema.d.ts +0 -87
  173. package/dist/content/schema.js +0 -85
  174. package/dist/content/validate.d.ts +0 -17
  175. package/dist/content/validate.js +0 -93
  176. package/src/lib/content/schema.ts +0 -163
  177. package/src/lib/content/validate.ts +0 -90
@@ -0,0 +1,310 @@
1
+ <!--
2
+ @component
3
+ The repeatable-row editor, the arm for a non-reference `array` container. It renders a list of rows,
4
+ each row either a single leaf (`array(text)`, `array(image)`) or a flat object group
5
+ (`array(object({...}))`), with keyboard-operable add, remove, and reorder. Each row collapses to its
6
+ `itemLabel` summary and expands to edit, the same buries-fewer-fields move the Details panel makes.
7
+
8
+ Rows are wrapped in a `{ id, value }` envelope so node identity follows a row through a reorder or a
9
+ remove and an in-progress edit (or the keyboard focus) never jumps to the wrong row. The id is a
10
+ seed-time counter, not a random uuid, so the server and client agree at hydration. The envelope is
11
+ UI-only; the form names derive from each row's CURRENT position (`${name}.${i}`), so the Task 3
12
+ decoder reads a compact, ordered set. The component seeds once from `rows`; the `{#key entryKey}`
13
+ wrapper in EditPage remounts it on an entry change, so it adds no re-seed effect.
14
+
15
+ A structural mutation (add, remove, reorder) marks the form dirty, because those do not fire the
16
+ form's `oninput`; a leaf edit inside a row does not, because the row inputs sit inside the edit form
17
+ whose `oninput` bubbles. An always-mounted polite live region announces add and remove.
18
+ -->
19
+ <script lang="ts">
20
+ import { tick, untrack } from 'svelte';
21
+ import { sortItems } from '@rodrigodagostino/svelte-sortable-list';
22
+ import FieldInput from './FieldInput.svelte';
23
+ import ObjectGroupField from './ObjectGroupField.svelte';
24
+ import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
25
+ import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
26
+ import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
27
+ import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
28
+ import Trash2Icon from '@lucide/svelte/icons/trash-2';
29
+ import PlusIcon from '@lucide/svelte/icons/plus';
30
+ import type { NamedField } from '../content/types.js';
31
+ import type { ArrayField, ObjectField } from '../content/fields.js';
32
+ import type { LinkTarget } from '../content/manifest.js';
33
+ import type { MediaEntry } from '../media/manifest.js';
34
+ import type { MediaLibraryEntry } from '../media/library-entry.js';
35
+ import type { IconSet } from '../render/glyph.js';
36
+ import type MediaHeroField from './MediaHeroField.svelte';
37
+
38
+ interface Props {
39
+ /** The array descriptor to render; its `item` is the per-row leaf or flat object. */
40
+ field: NamedField & ArrayField;
41
+ /** The form name prefix for this list; each row renders at `${name}.${i}`. */
42
+ name: string;
43
+ /** The seed rows: a list of leaf values, or a list of object slices for an object item. */
44
+ rows: unknown[];
45
+ /** The site link targets the reference arm offers (threaded through to each row). */
46
+ targets: LinkTarget[];
47
+ /** Mark the edit form dirty; called on add, remove, and reorder (these skip the form's oninput). */
48
+ markFieldsDirty: () => void;
49
+ /** The merged committed-plus-uploaded media library, keyed by content hash. */
50
+ mediaLibrary: Record<string, MediaLibraryEntry>;
51
+ /** The concept the entry belongs to (the upload action's route param). */
52
+ conceptId: string;
53
+ /** The entry id (the upload action's route param). */
54
+ id: string;
55
+ /** The host's hero-field refs, keyed by the prefixed `name` so two rows do not collide. */
56
+ heroFieldRefs: Record<string, MediaHeroField>;
57
+ /** Called with the server-owned record on a successful upload, so the host merges it. */
58
+ onuploaded: (record: MediaEntry) => void;
59
+ /** Called when a hero's needs-alt status changes, keyed by the prefixed `name`. */
60
+ onheroneedsalt: (name: string, needsAlt: boolean) => void;
61
+ /** The site's icon set, forwarded to each row's icon arm. */
62
+ icons?: IconSet;
63
+ }
64
+
65
+ let {
66
+ field,
67
+ name,
68
+ rows: seedRows,
69
+ targets,
70
+ markFieldsDirty,
71
+ mediaLibrary,
72
+ conceptId,
73
+ id,
74
+ heroFieldRefs,
75
+ onuploaded,
76
+ onheroneedsalt,
77
+ icons,
78
+ }: Props = $props();
79
+
80
+ /** One row of editable state: a stable id for keyed identity, and the row's current value. */
81
+ interface Row {
82
+ id: number;
83
+ value: unknown;
84
+ }
85
+
86
+ // The id of the next appended row. Seeded past the initial rows so a freshly added row never
87
+ // collides with a seeded one; a plain counter (not randomUUID) so SSR and the client agree.
88
+ let nextId = untrack(() => seedRows.length);
89
+
90
+ // The rows, seeded once from the prop and owned thereafter (untrack marks the read a deliberate
91
+ // one-time seed). No re-seed effect: EditPage's {#key entryKey} wrapper remounts on entry change.
92
+ let rows = $state<Row[]>(untrack(() => seedRows.map((value, i) => ({ id: i, value }))));
93
+
94
+ // Which rows are expanded for editing, keyed by row id; a collapsed row shows its summary only.
95
+ let expanded = $state<Record<number, boolean>>({});
96
+
97
+ // The polite announcement, mounted empty and filled on a structural mutation.
98
+ let announcement = $state('');
99
+
100
+ // The Add button, the last link in the remove focus chain (when no row remains to focus).
101
+ let addButton = $state<HTMLButtonElement | null>(null);
102
+
103
+ // This instance's outer fieldset. Every focus query scopes to it, so two RepeatableField lists on
104
+ // one page (the showcase posts concept renders both an array(object) and an array(image)) never
105
+ // move focus into the other list. A document-wide query would index across both row sets.
106
+ let root = $state<HTMLFieldSetElement | null>(null);
107
+
108
+ const isObjectItem = $derived(field.item.type === 'object');
109
+ const rowLabel = $derived(field.label ?? field.name);
110
+
111
+ // The live itemLabel text per row id, tracked so a collapsed row's summary follows the author's
112
+ // edits. The row inputs stay uncontrolled (the keyed envelope owns the seed; writing row.value on
113
+ // every keystroke risks edit loss), so this mirrors only the summary-label field, read on input.
114
+ let summaries = $state<Record<number, string>>({});
115
+
116
+ // The form name of a row's itemLabel field: the object sub-field path, or the leaf row path itself.
117
+ function summaryNameFor(index: number): string {
118
+ return isObjectItem && field.itemLabel != null
119
+ ? `${name}.${index}.${field.itemLabel}`
120
+ : `${name}.${index}`;
121
+ }
122
+
123
+ // Mirror a row's itemLabel value into the live summary map when its input fires. A non-summary
124
+ // input in the row is ignored, so the collapsed label tracks only the label field.
125
+ function onRowInput(row: Row, index: number, event: Event) {
126
+ const target = event.target as HTMLInputElement;
127
+ if (target.name === summaryNameFor(index)) {
128
+ summaries = { ...summaries, [row.id]: target.value };
129
+ }
130
+ }
131
+
132
+ // The collapsed summary for a row: the live itemLabel text when the author has edited it, else its
133
+ // seeded itemLabel value (object row) or the leaf value, falling back to a positional placeholder.
134
+ function summaryFor(value: unknown, index: number, rowId: number): string {
135
+ if (rowId in summaries) {
136
+ const live = summaries[rowId].trim();
137
+ if (live !== '') return live;
138
+ }
139
+ let text = '';
140
+ if (isObjectItem && field.itemLabel != null && value !== null && typeof value === 'object') {
141
+ text = String((value as Record<string, unknown>)[field.itemLabel] ?? '').trim();
142
+ } else if (!isObjectItem && value != null && typeof value !== 'object') {
143
+ text = String(value).trim();
144
+ }
145
+ return text !== '' ? text : `${rowLabel} ${index + 1}`;
146
+ }
147
+
148
+ // An empty value for a freshly added row: an empty object for an object item, an empty string for
149
+ // a leaf. The decoder prunes an all-default row, so an untouched added row never reaches the form.
150
+ function emptyValue(): unknown {
151
+ return isObjectItem ? {} : '';
152
+ }
153
+
154
+ function toggle(rowId: number) {
155
+ expanded = { ...expanded, [rowId]: !expanded[rowId] };
156
+ }
157
+
158
+ async function add() {
159
+ const row: Row = { id: nextId++, value: emptyValue() };
160
+ rows = [...rows, row];
161
+ expanded = { ...expanded, [row.id]: true };
162
+ markFieldsDirty();
163
+ announcement = 'Row added';
164
+ await tick();
165
+ // Land focus on the first operable control in the new row's EDIT BODY (not the row header's
166
+ // toggle/move/remove chrome). The selector spans an array(image) row too, whose MediaHeroField
167
+ // empty state is a <button> dropzone with only hidden inputs.
168
+ const firstInput = root?.querySelector<HTMLElement>(
169
+ `[data-cairn-row="${row.id}"] [data-cairn-row-body] :is(input:not([type=hidden]),textarea,select,button)`,
170
+ );
171
+ firstInput?.focus();
172
+ }
173
+
174
+ async function remove(index: number) {
175
+ rows = rows.filter((_, i) => i !== index);
176
+ markFieldsDirty();
177
+ announcement = 'Row removed';
178
+ await tick();
179
+ // Focus chain: the next row's remove control, else the previous row's, else the Add button.
180
+ const removeButtons = root?.querySelectorAll<HTMLElement>('[data-cairn-row-remove]') ?? [];
181
+ if (removeButtons[index]) removeButtons[index].focus();
182
+ else if (removeButtons[index - 1]) removeButtons[index - 1].focus();
183
+ else addButton?.focus();
184
+ }
185
+
186
+ async function move(index: number, dir: 1 | -1) {
187
+ const target = index + dir;
188
+ if (target < 0 || target >= rows.length) return;
189
+ rows = sortItems(rows, index, target);
190
+ markFieldsDirty();
191
+ await tick();
192
+ // Keep keyboard focus on the moved row after a reorder. The arrow that fired the move can become
193
+ // disabled at the first or last boundary, so focus the opposite-direction arrow, falling back to
194
+ // the row toggle. Both queries scope to this instance's fieldset.
195
+ const movedRow = root?.querySelectorAll<HTMLElement>('[data-cairn-row]')[target];
196
+ const opposite = dir === 1 ? '[data-cairn-row-up]' : '[data-cairn-row-down]';
197
+ const focusTarget =
198
+ movedRow?.querySelector<HTMLElement>(`${opposite}:not([disabled])`) ??
199
+ movedRow?.querySelector<HTMLElement>('[data-cairn-row-toggle]');
200
+ focusTarget?.focus();
201
+ }
202
+ </script>
203
+
204
+ <fieldset bind:this={root} class="m-0 flex min-w-0 flex-col gap-2 border-0 p-0">
205
+ <legend class="text-sm font-medium">{rowLabel}</legend>
206
+
207
+ {#if rows.length}
208
+ <ul class="flex flex-col gap-2">
209
+ {#each rows as row, i (row.id)}
210
+ {@const rowSummary = summaryFor(row.value, i, row.id)}
211
+ <li
212
+ class="rounded-[var(--radius-field)] border border-[var(--color-base-300)]"
213
+ data-cairn-row={row.id}
214
+ oninput={(e) => onRowInput(row, i, e)}
215
+ >
216
+ <div class="flex items-center gap-1 p-1">
217
+ <button
218
+ type="button"
219
+ class="btn btn-ghost btn-sm flex-1 justify-start gap-2 font-normal"
220
+ data-cairn-row-toggle
221
+ aria-expanded={expanded[row.id] ? 'true' : 'false'}
222
+ onclick={() => toggle(row.id)}
223
+ >
224
+ {#if expanded[row.id]}
225
+ <ChevronDownIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
226
+ {:else}
227
+ <ChevronRightIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
228
+ {/if}
229
+ <span class="truncate">{rowSummary}</span>
230
+ </button>
231
+ <button
232
+ type="button"
233
+ class="btn btn-ghost btn-sm btn-square"
234
+ data-cairn-row-up
235
+ aria-label={`Move ${rowSummary} up`}
236
+ disabled={i === 0}
237
+ onclick={() => move(i, -1)}
238
+ >
239
+ <ArrowUpIcon class="h-4 w-4" aria-hidden="true" />
240
+ </button>
241
+ <button
242
+ type="button"
243
+ class="btn btn-ghost btn-sm btn-square"
244
+ data-cairn-row-down
245
+ aria-label={`Move ${rowSummary} down`}
246
+ disabled={i === rows.length - 1}
247
+ onclick={() => move(i, 1)}
248
+ >
249
+ <ArrowDownIcon class="h-4 w-4" aria-hidden="true" />
250
+ </button>
251
+ <button
252
+ type="button"
253
+ class="btn btn-ghost btn-sm btn-square"
254
+ data-cairn-row-remove
255
+ aria-label={`Remove ${rowSummary}`}
256
+ onclick={() => remove(i)}
257
+ >
258
+ <Trash2Icon class="h-4 w-4" aria-hidden="true" />
259
+ </button>
260
+ </div>
261
+ {#if expanded[row.id]}
262
+ <div data-cairn-row-body class="flex flex-col gap-3 border-t border-[var(--color-base-300)] p-3">
263
+ {#if field.item.type === 'object'}
264
+ <ObjectGroupField
265
+ field={{ ...(field.item as ObjectField), name: field.name }}
266
+ name={`${name}.${i}`}
267
+ frontmatter={(row.value !== null && typeof row.value === 'object' ? row.value : {}) as Record<string, unknown>}
268
+ {targets}
269
+ {markFieldsDirty}
270
+ {mediaLibrary}
271
+ {conceptId}
272
+ {id}
273
+ {heroFieldRefs}
274
+ {onuploaded}
275
+ {onheroneedsalt}
276
+ {icons}
277
+ />
278
+ {:else}
279
+ <FieldInput
280
+ field={{ ...field.item, name: '_value' }}
281
+ name={`${name}.${i}`}
282
+ frontmatter={{ _value: row.value }}
283
+ {targets}
284
+ {markFieldsDirty}
285
+ {mediaLibrary}
286
+ {conceptId}
287
+ {id}
288
+ {heroFieldRefs}
289
+ {onuploaded}
290
+ {onheroneedsalt}
291
+ {icons}
292
+ />
293
+ {/if}
294
+ </div>
295
+ {/if}
296
+ </li>
297
+ {/each}
298
+ </ul>
299
+ {/if}
300
+
301
+ <div>
302
+ <button type="button" class="btn btn-sm btn-ghost gap-1" bind:this={addButton} onclick={add}>
303
+ <PlusIcon class="h-4 w-4" aria-hidden="true" />
304
+ Add {rowLabel}
305
+ </button>
306
+ </div>
307
+
308
+ <!-- Always mounted so add/remove announce consistently; a {#if}-gated region announces unevenly. -->
309
+ <div role="status" aria-live="polite" class="sr-only">{announcement}</div>
310
+ </fieldset>
@@ -64,9 +64,13 @@ export function buildPreviewDoc(html: string, preview: ResolvedPreview | null):
64
64
  // parent's base URL, so a clicked fragment or root link could render the admin login inside
65
65
  // the frame. Targeting every link at a new tab turns each click into a popup, and the sandbox
66
66
  // (which grants no allow-popups) blocks it, so a proofing click goes nowhere.
67
+ // The marker on the root lets a site scope an entrance animation (driven off [data-rise]) away
68
+ // from the preview, which shows the resting state of content and runs the same pipeline; without
69
+ // it, content would re-animate on every debounced render. cairn provides the hook; the site owns
70
+ // its animation and decides what to suppress under [data-cairn-preview].
67
71
  return [
68
72
  '<!doctype html>',
69
- '<html>',
73
+ '<html data-cairn-preview>',
70
74
  '<head>',
71
75
  '<meta charset="utf-8">',
72
76
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
@@ -62,7 +62,7 @@ const DIVERGENCE_FRACTION = 0.5;
62
62
  // text rather than going through extractMediaRefs for two reasons. First, a true MULTISET is the
63
63
  // invariant a backstop wants: extractMediaRefs dedups by hash, so a doubled token collapsing to one
64
64
  // would read as equal, and the validator must catch a dropped duplicate. Second, the raw scan covers
65
- // the whole text including frontmatter without threading the concept's FrontmatterField[] to the call
65
+ // the whole text including frontmatter without threading the concept's NamedField[] to the call
66
66
  // site, which the validator otherwise has no reason to know. A token mangled inside a code fence is
67
67
  // caught here too, redundantly with the code check, which is the right posture for a backstop.
68
68
  const MEDIA_TOKEN = /media:[A-Za-z0-9.-]+/g;
@@ -4,7 +4,28 @@
4
4
  // full-auto typed reads in createSiteIndexes, while still checking the adapter against the contract.
5
5
  import type { CairnAdapter } from './types.js';
6
6
 
7
+ // Fail closed on an inconsistent island registry: a hydrate component with no live component, or a
8
+ // registered island with no hydrate component. Either is a wiring mistake the site author should see at
9
+ // build time, not a silent forever-fallback. Read-only over the rendering group; imports no runtime.
10
+ function assertIslandsConsistent(rendering: CairnAdapter['rendering']): void {
11
+ const islands = rendering.islands ?? {};
12
+ const hydrated = new Set(
13
+ (rendering.components?.defs ?? []).filter((d) => d.hydrate).map((d) => d.name),
14
+ );
15
+ for (const name of hydrated) {
16
+ if (!(name in islands)) {
17
+ throw new Error(`cairn: component '${name}' declares hydrate but rendering.islands has no entry for it.`);
18
+ }
19
+ }
20
+ for (const name of Object.keys(islands)) {
21
+ if (!hydrated.has(name)) {
22
+ throw new Error(`cairn: rendering.islands has '${name}' but no component declares hydrate for it.`);
23
+ }
24
+ }
25
+ }
26
+
7
27
  /** Declare a site's adapter while preserving each concept's concrete schema type for typed reads. */
8
28
  export function defineAdapter<const A extends CairnAdapter>(adapter: A): A {
29
+ assertIslandsConsistent(adapter.rendering);
9
30
  return adapter;
10
31
  }
@@ -12,10 +12,8 @@
12
12
  // notice and never blocks the editor or the publish. The scope splits by call site: the main arm at
13
13
  // edit-load (synchronous, no extra GitHub read per open) and the full cross-branch check at publish.
14
14
  import type { ConceptDescriptor } from './types.js';
15
- import type { RepoRef } from '../github/types.js';
15
+ import type { Backend } from '../github/backend.js';
16
16
  import type { Manifest } from './manifest.js';
17
- import { listBranches } from '../github/branches.js';
18
- import { readRaw } from '../github/repo.js';
19
17
  import { PENDING_PREFIX, parsePendingBranch } from './pending.js';
20
18
  import { findConcept } from './concepts.js';
21
19
  import { isValidId, filenameFromId } from './ids.js';
@@ -90,8 +88,7 @@ export function mainAddressIndex(manifest: Manifest): AddressIndex {
90
88
  * publish. The branches are read in one Promise.all, the way buildUsageIndex reads them.
91
89
  */
92
90
  export async function buildAddressIndex(
93
- repo: RepoRef,
94
- token: string,
91
+ backend: Backend,
95
92
  concepts: ConceptDescriptor[],
96
93
  manifest: Manifest,
97
94
  ): Promise<AddressIndex> {
@@ -101,7 +98,7 @@ export async function buildAddressIndex(
101
98
 
102
99
  // The branch arm: read each open cairn/* branch's one edited file and resolve its permalink. The
103
100
  // path is derivable from the branch name, so no tree-listing is needed.
104
- const names = await listBranches(repo, PENDING_PREFIX, token);
101
+ const names = await backend.listBranches(PENDING_PREFIX);
105
102
  const perBranch = await Promise.all(
106
103
  names.map(async (name): Promise<{ permalink: string; entry: AddressEntry } | null> => {
107
104
  // Resolve the branch name with the branch tooling's guard: a malformed name, an id that fails
@@ -113,7 +110,7 @@ export async function buildAddressIndex(
113
110
 
114
111
  const path = `${concept.dir}/${filenameFromId(ref.id)}`;
115
112
  try {
116
- const raw = await readRaw({ ...repo, branch: name }, path, token);
113
+ const raw = await backend.readFile(path, name);
117
114
  if (raw === null) return null; // The file is absent on the branch: nothing to resolve.
118
115
  const { frontmatter } = parseMarkdown(raw);
119
116
  const fmTitle = frontmatter.title;
@@ -8,10 +8,18 @@ import { resolveConcepts } from './concepts.js';
8
8
  import { normalizeAssets } from '../media/config.js';
9
9
  import { dictionaryFileForDialect, type SiteConfig } from '../nav/site-config.js';
10
10
 
11
+ // The internal artifact paths the adapter does not carry. They share the `.cairn/` content root the
12
+ // manifests use, so `composeRuntime` defaults them by convention rather than reading them off config.
13
+ // The personal dictionary sits beside the manifests, so the spec's `content/.cairn/dictionary.txt`
14
+ // resolves the same configurable way the manifest paths do.
15
+ const CONTENT_MANIFEST_PATH = 'src/content/.cairn/index.json';
16
+ const MEDIA_MANIFEST_PATH = 'src/content/.cairn/media.json';
17
+ const DICTIONARY_PATH = 'src/content/.cairn/dictionary.txt';
18
+
11
19
  /**
12
- * The input to {@link composeRuntime}. `siteConfig` is required so the per-concept URL policy is
13
- * always derived from one source and can never be silently dropped. `extensions` fold in after the
14
- * adapter's concepts.
20
+ * The input to {@link composeRuntime}. `siteConfig` is required: it is the canonical home for the
21
+ * site name, the spellcheck dialect, and the tidy block, so they can never be silently dropped.
22
+ * `extensions` fold in after the adapter's concepts.
15
23
  */
16
24
  export interface ComposeInput {
17
25
  adapter: CairnAdapter;
@@ -20,13 +28,14 @@ export interface ComposeInput {
20
28
  }
21
29
 
22
30
  /**
23
- * Fold an adapter and any extensions into the composed runtime (seam 2). The per-concept URL policy
24
- * is derived from the site config, the same source the delivery path uses, so the runtime and
25
- * delivery permalinks cannot diverge. Extension concepts merge after the adapter's. The asset slot
31
+ * Fold an adapter and any extensions into the composed runtime (seam 2). This is the one place the
32
+ * grouped adapter maps onto the flat runtime, and the one place the internal manifest and dictionary
33
+ * paths default by convention. Each concept declares its own routing and URL policy, so the runtime
34
+ * and delivery permalinks cannot diverge. Extension concepts merge after the adapter's. The media slot
26
35
  * (seam 4) passes through untouched.
27
36
  */
28
37
  export function composeRuntime({ adapter, siteConfig, extensions = [] }: ComposeInput): CairnRuntime {
29
- if (!siteConfig) throw new Error('composeRuntime needs a site config to derive the URL policy');
38
+ if (!siteConfig) throw new Error('composeRuntime needs a site config for the site name and editor settings');
30
39
  const content: Record<string, ConceptConfig | undefined> = { ...adapter.content };
31
40
  const adminPanels: AdminPanel[] = [];
32
41
  const fieldTypes: FieldTypeDef[] = [];
@@ -38,23 +47,21 @@ export function composeRuntime({ adapter, siteConfig, extensions = [] }: Compose
38
47
  if (extension.fieldTypes) fieldTypes.push(...extension.fieldTypes);
39
48
  }
40
49
  return {
41
- siteName: adapter.siteName,
42
- concepts: resolveConcepts(content, siteConfig),
50
+ siteName: siteConfig.siteName,
51
+ concepts: resolveConcepts(content),
43
52
  backend: adapter.backend,
44
- sender: adapter.sender,
45
- supportContact: adapter.supportContact,
46
- render: adapter.render,
47
- manifestPath: adapter.manifestPath ?? 'src/content/.cairn/index.json',
48
- registry: adapter.registry,
49
- icons: adapter.icons,
50
- navMenu: adapter.navMenu,
51
- preview: adapter.preview,
52
- assets: adapter.assets,
53
- resolvedAssets: normalizeAssets(adapter.assets),
54
- mediaManifestPath: adapter.mediaManifestPath ?? 'src/content/.cairn/media.json',
55
- // The personal dictionary sits beside the manifests under the same `.cairn/` content root, so the
56
- // spec's `content/.cairn/dictionary.txt` resolves the same configurable way the manifest paths do.
57
- dictionaryPath: adapter.dictionaryPath ?? 'src/content/.cairn/dictionary.txt',
53
+ sender: adapter.email,
54
+ supportContact: adapter.editor?.supportContact,
55
+ render: adapter.rendering.render,
56
+ manifestPath: CONTENT_MANIFEST_PATH,
57
+ registry: adapter.rendering.components,
58
+ icons: adapter.rendering.icons,
59
+ navMenu: adapter.editor?.nav,
60
+ preview: adapter.editor?.preview,
61
+ assets: adapter.media,
62
+ resolvedAssets: normalizeAssets(adapter.media),
63
+ mediaManifestPath: MEDIA_MANIFEST_PATH,
64
+ dictionaryPath: DICTIONARY_PATH,
58
65
  // The spellcheck dictionary is resolved once here from the site config's dialect (default US),
59
66
  // so the runtime and the editor never re-derive it. The site config is the one home for the
60
67
  // dialect; the editor resolves this filename to a real asset URL on the main thread.
@@ -1,23 +1,41 @@
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, ConceptUrlPolicy, RoutingRule } from './types.js';
7
- import { urlPolicyFrom, type SiteConfig } from '../nav/site-config.js';
1
+ // cairn-cms: concept normalization (seam 1). The adapter declares concepts as an open `content`
2
+ // record; this turns each declared key into a uniform descriptor (id, label, directory, declared
3
+ // routing, fields, validator) the admin reads. A new concept attaches by adding one key under
4
+ // `content` and declaring its own routing and URL policy, with no reshape here.
5
+ import type { ConceptConfig, ConceptDescriptor, ConceptUrlPolicy, NamedField, RoutingRule } from './types.js';
6
+ import type { Fieldset } from './fieldset.js';
8
7
 
9
- /**
10
- * Concept-fixed routing, keyed by concept id (spec §7.2). Posts are dated feed entries;
11
- * pages are plain navigable structure. Not in adapter config. A future Fragments adds one
12
- * entry here and one key under `content`.
13
- */
14
- export const CONCEPT_ROUTING: Readonly<Record<string, RoutingRule>> = {
15
- posts: { routable: true, dated: true, inFeeds: true },
16
- pages: { routable: true, dated: false, inFeeds: false },
8
+ /** Re-attach each fieldset record key to its descriptor as `name`, the normalized `NamedField[]`. */
9
+ function namedFields(schema: Fieldset): NamedField[] {
10
+ return Object.entries(schema.fields).map(([name, descriptor]) => ({ name, ...descriptor }));
11
+ }
12
+
13
+ /** The named routing shorthands, each expanding to a concrete rule. */
14
+ const ROUTING_SHORTHANDS: Readonly<Record<'feed' | 'page' | 'embedded', RoutingRule>> = {
15
+ feed: { routable: true, dated: true, inFeeds: true },
16
+ page: { routable: true, dated: false, inFeeds: false },
17
+ embedded: { routable: false, dated: false, inFeeds: false },
17
18
  };
18
19
 
19
- /** Routing for a concept with no table entry: a plain, non-feed, routable page. */
20
- const DEFAULT_ROUTING: RoutingRule = { routable: true, dated: false, inFeeds: false };
20
+ /** Expand a concept's routing shorthand to a concrete rule. The single resolution point: omitted is `page`. */
21
+ export function resolveRouting(routing: ConceptConfig['routing']): RoutingRule {
22
+ if (routing === undefined) return ROUTING_SHORTHANDS.page;
23
+ return typeof routing === 'string' ? ROUTING_SHORTHANDS[routing] : routing;
24
+ }
25
+
26
+ /**
27
+ * Declare a concept while preserving its fieldset type for typed reads, and validate its URL policy at
28
+ * declaration so a bad permalink or datePrefix fails at module load rather than at a defaulted render.
29
+ * Mirrors {@link defineAdapter}; the validation is the build-independent net for a concept with no entries.
30
+ */
31
+ export function defineConcept<const C extends ConceptConfig>(concept: C): C {
32
+ validateUrlPolicy(
33
+ concept.label ?? concept.dir,
34
+ { permalink: concept.permalink, datePrefix: concept.datePrefix },
35
+ resolveRouting(concept.routing).dated,
36
+ );
37
+ return concept;
38
+ }
21
39
 
22
40
  /** Title-case a concept id for the default sidebar label, e.g. "posts" to "Posts". */
23
41
  function defaultLabel(id: string): string {
@@ -41,7 +59,7 @@ const DATE_PREFIXES = new Set<string>(['year', 'month', 'day']);
41
59
  * here rather than emitting a wrong or defaulted URL at render. The permalink must be root-relative and
42
60
  * use only known tokens, a date token requires a dated concept, and the datePrefix must be in range.
43
61
  */
44
- function validateUrlPolicy(id: string, policy: ConceptUrlPolicy, dated: boolean): void {
62
+ export function validateUrlPolicy(id: string, policy: ConceptUrlPolicy, dated: boolean): void {
45
63
  if (policy.permalink !== undefined) {
46
64
  const pattern = policy.permalink;
47
65
  if (!pattern.startsWith('/')) {
@@ -67,38 +85,48 @@ function validateUrlPolicy(id: string, policy: ConceptUrlPolicy, dated: boolean)
67
85
  }
68
86
 
69
87
  /**
70
- * Normalize an adapter's declared concepts into uniform descriptors (seam 1). URL policy
71
- * (`permalink`, `datePrefix`) comes from the YAML site-config, passed here as `urlPolicy` keyed by
72
- * concept id; each value defaults when the YAML omits it (`/:slug` for Pages, `/<id>/:slug`
73
- * otherwise; `datePrefix` defaults to `day`). `routing` is injectable so a contract test can prove
74
- * a new concept attaches additively; production passes the default `CONCEPT_ROUTING`.
88
+ * Normalize an adapter's declared concepts into uniform descriptors (seam 1). Each concept declares its
89
+ * own routing (a shorthand or an explicit rule, resolved by `resolveRouting`) and URL policy
90
+ * (`permalink`, `datePrefix`) on the config; both default when omitted (`/:slug` for Pages, `/<id>/:slug`
91
+ * otherwise; `datePrefix` defaults to `day`). A new concept attaches by adding one key under `content`.
75
92
  */
76
93
  export function normalizeConcepts(
77
94
  content: Record<string, ConceptConfig | undefined>,
78
- urlPolicy: Record<string, ConceptUrlPolicy | undefined> = {},
79
- routing: Readonly<Record<string, RoutingRule>> = CONCEPT_ROUTING,
80
95
  ): ConceptDescriptor[] {
81
96
  const descriptors: ConceptDescriptor[] = [];
82
97
  const declaredConcepts = new Set(
83
98
  Object.keys(content).filter((key) => content[key] !== undefined),
84
99
  );
85
- for (const key of Object.keys(urlPolicy)) {
86
- if (!declaredConcepts.has(key)) {
87
- throw new Error(`cairn: URL policy names concept "${key}", which is not declared under content`);
88
- }
89
- }
90
100
  for (const [id, config] of Object.entries(content)) {
91
101
  if (!config) continue;
102
+ const fs = config.fields;
92
103
  const summaryFields = config.summaryFields ?? [];
93
- const declared = new Set(config.schema.fields.map((field) => field.name));
104
+ const declared = new Set(Object.keys(fs.fields));
94
105
  const undeclared = summaryFields.find((key) => !declared.has(key));
95
106
  if (undeclared !== undefined) {
96
107
  throw new Error(
97
108
  `cairn: concept "${id}" summaryFields key "${undeclared}" is not a declared field`,
98
109
  );
99
110
  }
100
- const conceptRouting = routing[id] ?? DEFAULT_ROUTING;
101
- const policy = urlPolicy[id] ?? {};
111
+ // A reference (or array of reference) field names the concept it targets. Validate that concept at
112
+ // declaration, so a typo fails loudly here rather than at the build's verifyReferences gate (or, in
113
+ // the editor picker, as a silently empty target list). The check is the field descriptor's concept
114
+ // against the declared content keys.
115
+ for (const [name, descriptor] of Object.entries(fs.fields)) {
116
+ const targetConcept =
117
+ descriptor.type === 'reference'
118
+ ? descriptor.concept
119
+ : descriptor.type === 'array' && descriptor.item.type === 'reference'
120
+ ? descriptor.item.concept
121
+ : undefined;
122
+ if (targetConcept !== undefined && !declaredConcepts.has(targetConcept)) {
123
+ throw new Error(
124
+ `cairn: concept "${id}" reference field "${name}" names concept "${targetConcept}", which is not declared under content`,
125
+ );
126
+ }
127
+ }
128
+ const conceptRouting = resolveRouting(config.routing);
129
+ const policy: ConceptUrlPolicy = { permalink: config.permalink, datePrefix: config.datePrefix };
102
130
  validateUrlPolicy(id, policy, conceptRouting.dated);
103
131
  const label = config.label ?? defaultLabel(id);
104
132
  descriptors.push({
@@ -109,24 +137,24 @@ export function normalizeConcepts(
109
137
  routing: conceptRouting,
110
138
  permalink: policy.permalink ?? defaultPermalink(id),
111
139
  datePrefix: policy.datePrefix ?? 'day',
112
- fields: config.schema.fields,
140
+ fields: namedFields(fs),
141
+ schema: fs,
113
142
  summaryFields,
114
- validate: config.schema.validate,
143
+ validate: fs.validate,
115
144
  });
116
145
  }
117
146
  return descriptors;
118
147
  }
119
148
 
120
149
  /**
121
- * Resolve a site's concept descriptors from its content map and parsed site config. The admin runtime
122
- * (composeRuntime) and the delivery layer (siteDescriptors) both call this, so the per-concept URL
123
- * policy is derived once from the YAML and the runtime and delivery permalinks cannot diverge.
150
+ * Resolve a site's concept descriptors from its content map. The admin runtime (composeRuntime) and the
151
+ * delivery layer (siteDescriptors) both call this, so the per-concept routing and URL policy are derived
152
+ * once from the concept declarations and the runtime and delivery permalinks cannot diverge.
124
153
  */
125
154
  export function resolveConcepts(
126
155
  content: Record<string, ConceptConfig | undefined>,
127
- siteConfig: SiteConfig,
128
156
  ): ConceptDescriptor[] {
129
- return normalizeConcepts(content, urlPolicyFrom(siteConfig));
157
+ return normalizeConcepts(content);
130
158
  }
131
159
 
132
160
  /** Look up a normalized concept by id, or undefined when the site does not enable it. */