@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,94 @@
1
+ <!--
2
+ @component
3
+ The reference field editor arm. A single `reference` renders a combobox-style button showing the
4
+ current target's resolved title (looked up in the site's link targets), opening EntryPicker scoped to
5
+ the field's concept; on pick it sets the value and emits one hidden input the decoder reads. A many
6
+ `array(reference)` renders a removable chip list, each chip showing the target's resolved title, plus
7
+ an EntryPicker that marks the already-held ids and adds another; it emits one hidden input per selected
8
+ id, so frontmatterFromForm's getAll reads them all. EntryPicker owns the search and grouped list; this
9
+ component owns the cardinality, the chips, and the hidden inputs the form submits.
10
+ -->
11
+ <script lang="ts">import { untrack } from "svelte";
12
+ import EntryPicker from "./EntryPicker.svelte";
13
+ let { field, value, targets, ondirty } = $props();
14
+ const concept = $derived.by(() => {
15
+ if (field.type === "array") return field.item.concept;
16
+ if (field.type === "reference") return field.concept;
17
+ return "";
18
+ });
19
+ let singleId = $state(untrack(() => typeof value === "string" ? value : ""));
20
+ let ids = $state(untrack(() => Array.isArray(value) ? [...value] : []));
21
+ let picker = $state(null);
22
+ function titleFor(id) {
23
+ return targets.find((t) => t.concept === concept && t.id === id)?.title ?? id;
24
+ }
25
+ function chooseSingle(target) {
26
+ singleId = target.id;
27
+ ondirty?.();
28
+ }
29
+ function chooseMany(target) {
30
+ if (!ids.includes(target.id)) {
31
+ ids = [...ids, target.id];
32
+ ondirty?.();
33
+ }
34
+ }
35
+ function remove(id) {
36
+ ids = ids.filter((x) => x !== id);
37
+ ondirty?.();
38
+ }
39
+ </script>
40
+
41
+ {#if field.type === 'array'}
42
+ <fieldset class="m-0 flex min-w-0 flex-col gap-2 border-0 p-0">
43
+ <legend class="text-sm font-medium">{field.label}</legend>
44
+ {#if ids.length}
45
+ <ul class="flex flex-wrap gap-2">
46
+ {#each ids as id (id)}
47
+ <li class="badge badge-ghost gap-1">
48
+ <span>{titleFor(id)}</span>
49
+ <button type="button" class="btn btn-ghost btn-xs btn-square" aria-label={`Remove ${titleFor(id)}`} onclick={() => remove(id)}>
50
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
51
+ </button>
52
+ <input type="hidden" name={field.name} value={id} />
53
+ </li>
54
+ {/each}
55
+ </ul>
56
+ {/if}
57
+ <div>
58
+ <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label={`Add ${field.label}`} onclick={() => picker?.open()}>
59
+ Add {field.label}
60
+ </button>
61
+ </div>
62
+ </fieldset>
63
+ <EntryPicker
64
+ bind:this={picker}
65
+ {targets}
66
+ choose={chooseMany}
67
+ conceptFilter={concept}
68
+ selectedIds={ids}
69
+ trigger={false}
70
+ heading={`Choose ${field.label}`}
71
+ searchLabel={`Search ${concept}`}
72
+ emptyText={`No ${concept} to choose.`}
73
+ />
74
+ {:else}
75
+ <div class="flex flex-col gap-1">
76
+ <span class="text-sm font-medium">{field.label}</span>
77
+ <button type="button" class="btn btn-sm btn-ghost justify-start" aria-haspopup="dialog" aria-label={field.label} onclick={() => picker?.open()}>
78
+ {#if singleId}{titleFor(singleId)}{:else}<span class="text-[var(--color-muted)]">Choose {field.label}</span>{/if}
79
+ </button>
80
+ {#if singleId}
81
+ <input type="hidden" name={field.name} value={singleId} />
82
+ {/if}
83
+ </div>
84
+ <EntryPicker
85
+ bind:this={picker}
86
+ {targets}
87
+ choose={chooseSingle}
88
+ conceptFilter={concept}
89
+ trigger={false}
90
+ heading={`Choose ${field.label}`}
91
+ searchLabel={`Search ${concept}`}
92
+ emptyText={`No ${concept} to choose.`}
93
+ />
94
+ {/if}
@@ -0,0 +1,27 @@
1
+ import type { LinkTarget } from '../content/manifest.js';
2
+ import type { ReferenceField } from '../content/fields.js';
3
+ import type { NamedField } from '../content/types.js';
4
+ interface Props {
5
+ /** The reference or array(reference) descriptor this arm renders. */
6
+ field: NamedField;
7
+ /** The current value: one id for a single reference, a list of ids for an array. */
8
+ value: string | string[];
9
+ /** The site's link targets, from the committed manifest (editLoad ships them). */
10
+ targets: LinkTarget[];
11
+ /** Called when the committed ids change (a pick, an add, or a remove), so the host sets
12
+ * fieldsDirty. The hidden-input writes do not fire the form's oninput, so the field signals
13
+ * dirty explicitly, the same way MediaHeroField does. */
14
+ ondirty?: () => void;
15
+ }
16
+ /**
17
+ * The reference field editor arm. A single `reference` renders a combobox-style button showing the
18
+ * current target's resolved title (looked up in the site's link targets), opening EntryPicker scoped to
19
+ * the field's concept; on pick it sets the value and emits one hidden input the decoder reads. A many
20
+ * `array(reference)` renders a removable chip list, each chip showing the target's resolved title, plus
21
+ * an EntryPicker that marks the already-held ids and adds another; it emits one hidden input per selected
22
+ * id, so frontmatterFromForm's getAll reads them all. EntryPicker owns the search and grouped list; this
23
+ * component owns the cardinality, the chips, and the hidden inputs the form submits.
24
+ */
25
+ declare const ReferenceField: import("svelte").Component<Props, {}, "">;
26
+ type ReferenceField = ReturnType<typeof ReferenceField>;
27
+ export default ReferenceField;
@@ -0,0 +1,221 @@
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">import { tick, untrack } from "svelte";
20
+ import { sortItems } from "@rodrigodagostino/svelte-sortable-list";
21
+ import FieldInput from "./FieldInput.svelte";
22
+ import ObjectGroupField from "./ObjectGroupField.svelte";
23
+ import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
24
+ import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
25
+ import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
26
+ import ArrowDownIcon from "@lucide/svelte/icons/arrow-down";
27
+ import Trash2Icon from "@lucide/svelte/icons/trash-2";
28
+ import PlusIcon from "@lucide/svelte/icons/plus";
29
+ let {
30
+ field,
31
+ name,
32
+ rows: seedRows,
33
+ targets,
34
+ markFieldsDirty,
35
+ mediaLibrary,
36
+ conceptId,
37
+ id,
38
+ heroFieldRefs,
39
+ onuploaded,
40
+ onheroneedsalt,
41
+ icons
42
+ } = $props();
43
+ let nextId = untrack(() => seedRows.length);
44
+ let rows = $state(untrack(() => seedRows.map((value, i) => ({ id: i, value }))));
45
+ let expanded = $state({});
46
+ let announcement = $state("");
47
+ let addButton = $state(null);
48
+ let root = $state(null);
49
+ const isObjectItem = $derived(field.item.type === "object");
50
+ const rowLabel = $derived(field.label ?? field.name);
51
+ let summaries = $state({});
52
+ function summaryNameFor(index) {
53
+ return isObjectItem && field.itemLabel != null ? `${name}.${index}.${field.itemLabel}` : `${name}.${index}`;
54
+ }
55
+ function onRowInput(row, index, event) {
56
+ const target = event.target;
57
+ if (target.name === summaryNameFor(index)) {
58
+ summaries = { ...summaries, [row.id]: target.value };
59
+ }
60
+ }
61
+ function summaryFor(value, index, rowId) {
62
+ if (rowId in summaries) {
63
+ const live = summaries[rowId].trim();
64
+ if (live !== "") return live;
65
+ }
66
+ let text = "";
67
+ if (isObjectItem && field.itemLabel != null && value !== null && typeof value === "object") {
68
+ text = String(value[field.itemLabel] ?? "").trim();
69
+ } else if (!isObjectItem && value != null && typeof value !== "object") {
70
+ text = String(value).trim();
71
+ }
72
+ return text !== "" ? text : `${rowLabel} ${index + 1}`;
73
+ }
74
+ function emptyValue() {
75
+ return isObjectItem ? {} : "";
76
+ }
77
+ function toggle(rowId) {
78
+ expanded = { ...expanded, [rowId]: !expanded[rowId] };
79
+ }
80
+ async function add() {
81
+ const row = { id: nextId++, value: emptyValue() };
82
+ rows = [...rows, row];
83
+ expanded = { ...expanded, [row.id]: true };
84
+ markFieldsDirty();
85
+ announcement = "Row added";
86
+ await tick();
87
+ const firstInput = root?.querySelector(
88
+ `[data-cairn-row="${row.id}"] [data-cairn-row-body] :is(input:not([type=hidden]),textarea,select,button)`
89
+ );
90
+ firstInput?.focus();
91
+ }
92
+ async function remove(index) {
93
+ rows = rows.filter((_, i) => i !== index);
94
+ markFieldsDirty();
95
+ announcement = "Row removed";
96
+ await tick();
97
+ const removeButtons = root?.querySelectorAll("[data-cairn-row-remove]") ?? [];
98
+ if (removeButtons[index]) removeButtons[index].focus();
99
+ else if (removeButtons[index - 1]) removeButtons[index - 1].focus();
100
+ else addButton?.focus();
101
+ }
102
+ async function move(index, dir) {
103
+ const target = index + dir;
104
+ if (target < 0 || target >= rows.length) return;
105
+ rows = sortItems(rows, index, target);
106
+ markFieldsDirty();
107
+ await tick();
108
+ const movedRow = root?.querySelectorAll("[data-cairn-row]")[target];
109
+ const opposite = dir === 1 ? "[data-cairn-row-up]" : "[data-cairn-row-down]";
110
+ const focusTarget = movedRow?.querySelector(`${opposite}:not([disabled])`) ?? movedRow?.querySelector("[data-cairn-row-toggle]");
111
+ focusTarget?.focus();
112
+ }
113
+ </script>
114
+
115
+ <fieldset bind:this={root} class="m-0 flex min-w-0 flex-col gap-2 border-0 p-0">
116
+ <legend class="text-sm font-medium">{rowLabel}</legend>
117
+
118
+ {#if rows.length}
119
+ <ul class="flex flex-col gap-2">
120
+ {#each rows as row, i (row.id)}
121
+ {@const rowSummary = summaryFor(row.value, i, row.id)}
122
+ <li
123
+ class="rounded-[var(--radius-field)] border border-[var(--color-base-300)]"
124
+ data-cairn-row={row.id}
125
+ oninput={(e) => onRowInput(row, i, e)}
126
+ >
127
+ <div class="flex items-center gap-1 p-1">
128
+ <button
129
+ type="button"
130
+ class="btn btn-ghost btn-sm flex-1 justify-start gap-2 font-normal"
131
+ data-cairn-row-toggle
132
+ aria-expanded={expanded[row.id] ? 'true' : 'false'}
133
+ onclick={() => toggle(row.id)}
134
+ >
135
+ {#if expanded[row.id]}
136
+ <ChevronDownIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
137
+ {:else}
138
+ <ChevronRightIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
139
+ {/if}
140
+ <span class="truncate">{rowSummary}</span>
141
+ </button>
142
+ <button
143
+ type="button"
144
+ class="btn btn-ghost btn-sm btn-square"
145
+ data-cairn-row-up
146
+ aria-label={`Move ${rowSummary} up`}
147
+ disabled={i === 0}
148
+ onclick={() => move(i, -1)}
149
+ >
150
+ <ArrowUpIcon class="h-4 w-4" aria-hidden="true" />
151
+ </button>
152
+ <button
153
+ type="button"
154
+ class="btn btn-ghost btn-sm btn-square"
155
+ data-cairn-row-down
156
+ aria-label={`Move ${rowSummary} down`}
157
+ disabled={i === rows.length - 1}
158
+ onclick={() => move(i, 1)}
159
+ >
160
+ <ArrowDownIcon class="h-4 w-4" aria-hidden="true" />
161
+ </button>
162
+ <button
163
+ type="button"
164
+ class="btn btn-ghost btn-sm btn-square"
165
+ data-cairn-row-remove
166
+ aria-label={`Remove ${rowSummary}`}
167
+ onclick={() => remove(i)}
168
+ >
169
+ <Trash2Icon class="h-4 w-4" aria-hidden="true" />
170
+ </button>
171
+ </div>
172
+ {#if expanded[row.id]}
173
+ <div data-cairn-row-body class="flex flex-col gap-3 border-t border-[var(--color-base-300)] p-3">
174
+ {#if field.item.type === 'object'}
175
+ <ObjectGroupField
176
+ field={{ ...(field.item as ObjectField), name: field.name }}
177
+ name={`${name}.${i}`}
178
+ frontmatter={(row.value !== null && typeof row.value === 'object' ? row.value : {}) as Record<string, unknown>}
179
+ {targets}
180
+ {markFieldsDirty}
181
+ {mediaLibrary}
182
+ {conceptId}
183
+ {id}
184
+ {heroFieldRefs}
185
+ {onuploaded}
186
+ {onheroneedsalt}
187
+ {icons}
188
+ />
189
+ {:else}
190
+ <FieldInput
191
+ field={{ ...field.item, name: '_value' }}
192
+ name={`${name}.${i}`}
193
+ frontmatter={{ _value: row.value }}
194
+ {targets}
195
+ {markFieldsDirty}
196
+ {mediaLibrary}
197
+ {conceptId}
198
+ {id}
199
+ {heroFieldRefs}
200
+ {onuploaded}
201
+ {onheroneedsalt}
202
+ {icons}
203
+ />
204
+ {/if}
205
+ </div>
206
+ {/if}
207
+ </li>
208
+ {/each}
209
+ </ul>
210
+ {/if}
211
+
212
+ <div>
213
+ <button type="button" class="btn btn-sm btn-ghost gap-1" bind:this={addButton} onclick={add}>
214
+ <PlusIcon class="h-4 w-4" aria-hidden="true" />
215
+ Add {rowLabel}
216
+ </button>
217
+ </div>
218
+
219
+ <!-- Always mounted so add/remove announce consistently; a {#if}-gated region announces unevenly. -->
220
+ <div role="status" aria-live="polite" class="sr-only">{announcement}</div>
221
+ </fieldset>
@@ -0,0 +1,53 @@
1
+ import type { NamedField } from '../content/types.js';
2
+ import type { ArrayField } from '../content/fields.js';
3
+ import type { LinkTarget } from '../content/manifest.js';
4
+ import type { MediaEntry } from '../media/manifest.js';
5
+ import type { MediaLibraryEntry } from '../media/library-entry.js';
6
+ import type { IconSet } from '../render/glyph.js';
7
+ import type MediaHeroField from './MediaHeroField.svelte';
8
+ interface Props {
9
+ /** The array descriptor to render; its `item` is the per-row leaf or flat object. */
10
+ field: NamedField & ArrayField;
11
+ /** The form name prefix for this list; each row renders at `${name}.${i}`. */
12
+ name: string;
13
+ /** The seed rows: a list of leaf values, or a list of object slices for an object item. */
14
+ rows: unknown[];
15
+ /** The site link targets the reference arm offers (threaded through to each row). */
16
+ targets: LinkTarget[];
17
+ /** Mark the edit form dirty; called on add, remove, and reorder (these skip the form's oninput). */
18
+ markFieldsDirty: () => void;
19
+ /** The merged committed-plus-uploaded media library, keyed by content hash. */
20
+ mediaLibrary: Record<string, MediaLibraryEntry>;
21
+ /** The concept the entry belongs to (the upload action's route param). */
22
+ conceptId: string;
23
+ /** The entry id (the upload action's route param). */
24
+ id: string;
25
+ /** The host's hero-field refs, keyed by the prefixed `name` so two rows do not collide. */
26
+ heroFieldRefs: Record<string, MediaHeroField>;
27
+ /** Called with the server-owned record on a successful upload, so the host merges it. */
28
+ onuploaded: (record: MediaEntry) => void;
29
+ /** Called when a hero's needs-alt status changes, keyed by the prefixed `name`. */
30
+ onheroneedsalt: (name: string, needsAlt: boolean) => void;
31
+ /** The site's icon set, forwarded to each row's icon arm. */
32
+ icons?: IconSet;
33
+ }
34
+ /**
35
+ * The repeatable-row editor, the arm for a non-reference `array` container. It renders a list of rows,
36
+ * each row either a single leaf (`array(text)`, `array(image)`) or a flat object group
37
+ * (`array(object({...}))`), with keyboard-operable add, remove, and reorder. Each row collapses to its
38
+ * `itemLabel` summary and expands to edit, the same buries-fewer-fields move the Details panel makes.
39
+ *
40
+ * Rows are wrapped in a `{ id, value }` envelope so node identity follows a row through a reorder or a
41
+ * remove and an in-progress edit (or the keyboard focus) never jumps to the wrong row. The id is a
42
+ * seed-time counter, not a random uuid, so the server and client agree at hydration. The envelope is
43
+ * UI-only; the form names derive from each row's CURRENT position (`${name}.${i}`), so the Task 3
44
+ * decoder reads a compact, ordered set. The component seeds once from `rows`; the `{#key entryKey}`
45
+ * wrapper in EditPage remounts it on an entry change, so it adds no re-seed effect.
46
+ *
47
+ * A structural mutation (add, remove, reorder) marks the form dirty, because those do not fire the
48
+ * form's `oninput`; a leaf edit inside a row does not, because the row inputs sit inside the edit form
49
+ * whose `oninput` bubbles. An always-mounted polite live region announces add and remove.
50
+ */
51
+ declare const RepeatableField: import("svelte").Component<Props, {}, "">;
52
+ type RepeatableField = ReturnType<typeof RepeatableField>;
53
+ export default RepeatableField;
@@ -5641,6 +5641,10 @@
5641
5641
  border-color: var(--cairn-error-border);
5642
5642
  }
5643
5643
 
5644
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[var\(--color-base-300\)\] {
5645
+ border-color: var(--color-base-300);
5646
+ }
5647
+
5644
5648
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[var\(--color-positive-ink\)\]\/\[0\.22\] {
5645
5649
  border-color: var(--color-positive-ink);
5646
5650
  }
@@ -46,9 +46,13 @@ export function buildPreviewDoc(html, preview) {
46
46
  // parent's base URL, so a clicked fragment or root link could render the admin login inside
47
47
  // the frame. Targeting every link at a new tab turns each click into a popup, and the sandbox
48
48
  // (which grants no allow-popups) blocks it, so a proofing click goes nowhere.
49
+ // The marker on the root lets a site scope an entrance animation (driven off [data-rise]) away
50
+ // from the preview, which shows the resting state of content and runs the same pipeline; without
51
+ // it, content would re-animate on every debounced render. cairn provides the hook; the site owns
52
+ // its animation and decides what to suppress under [data-cairn-preview].
49
53
  return [
50
54
  '<!doctype html>',
51
- '<html>',
55
+ '<html data-cairn-preview>',
52
56
  '<head>',
53
57
  '<meta charset="utf-8">',
54
58
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
@@ -36,7 +36,7 @@ const DIVERGENCE_FRACTION = 0.5;
36
36
  // text rather than going through extractMediaRefs for two reasons. First, a true MULTISET is the
37
37
  // invariant a backstop wants: extractMediaRefs dedups by hash, so a doubled token collapsing to one
38
38
  // would read as equal, and the validator must catch a dropped duplicate. Second, the raw scan covers
39
- // the whole text including frontmatter without threading the concept's FrontmatterField[] to the call
39
+ // the whole text including frontmatter without threading the concept's NamedField[] to the call
40
40
  // site, which the validator otherwise has no reason to know. A token mangled inside a code fence is
41
41
  // caught here too, redundantly with the code check, which is the right posture for a backstop.
42
42
  const MEDIA_TOKEN = /media:[A-Za-z0-9.-]+/g;
@@ -1,4 +1,22 @@
1
+ // Fail closed on an inconsistent island registry: a hydrate component with no live component, or a
2
+ // registered island with no hydrate component. Either is a wiring mistake the site author should see at
3
+ // build time, not a silent forever-fallback. Read-only over the rendering group; imports no runtime.
4
+ function assertIslandsConsistent(rendering) {
5
+ const islands = rendering.islands ?? {};
6
+ const hydrated = new Set((rendering.components?.defs ?? []).filter((d) => d.hydrate).map((d) => d.name));
7
+ for (const name of hydrated) {
8
+ if (!(name in islands)) {
9
+ throw new Error(`cairn: component '${name}' declares hydrate but rendering.islands has no entry for it.`);
10
+ }
11
+ }
12
+ for (const name of Object.keys(islands)) {
13
+ if (!hydrated.has(name)) {
14
+ throw new Error(`cairn: rendering.islands has '${name}' but no component declares hydrate for it.`);
15
+ }
16
+ }
17
+ }
1
18
  /** Declare a site's adapter while preserving each concept's concrete schema type for typed reads. */
2
19
  export function defineAdapter(adapter) {
20
+ assertIslandsConsistent(adapter.rendering);
3
21
  return adapter;
4
22
  }
@@ -1,5 +1,5 @@
1
1
  import type { ConceptDescriptor } from './types.js';
2
- import type { RepoRef } from '../github/types.js';
2
+ import type { Backend } from '../github/backend.js';
3
3
  import type { Manifest } from './manifest.js';
4
4
  /** One action an advisory offers, as a label and an optional link target. */
5
5
  export interface AdvisoryAction {
@@ -45,7 +45,7 @@ export declare function mainAddressIndex(manifest: Manifest): AddressIndex;
45
45
  * and skipped, so a transient failure degrades to a thinner index, never a thrown editor or a blocked
46
46
  * publish. The branches are read in one Promise.all, the way buildUsageIndex reads them.
47
47
  */
48
- export declare function buildAddressIndex(repo: RepoRef, token: string, concepts: ConceptDescriptor[], manifest: Manifest): Promise<AddressIndex>;
48
+ export declare function buildAddressIndex(backend: Backend, concepts: ConceptDescriptor[], manifest: Manifest): Promise<AddressIndex>;
49
49
  /**
50
50
  * Find the first other entry that already resolves to an address, or null when the address is free
51
51
  * or holds only the entry itself. The self entry is identified by its concept and id together.
@@ -1,5 +1,3 @@
1
- import { listBranches } from '../github/branches.js';
2
- import { readRaw } from '../github/repo.js';
3
1
  import { PENDING_PREFIX, parsePendingBranch } from './pending.js';
4
2
  import { findConcept } from './concepts.js';
5
3
  import { isValidId, filenameFromId } from './ids.js';
@@ -37,13 +35,13 @@ export function mainAddressIndex(manifest) {
37
35
  * and skipped, so a transient failure degrades to a thinner index, never a thrown editor or a blocked
38
36
  * publish. The branches are read in one Promise.all, the way buildUsageIndex reads them.
39
37
  */
40
- export async function buildAddressIndex(repo, token, concepts, manifest) {
38
+ export async function buildAddressIndex(backend, concepts, manifest) {
41
39
  // The main arm: the manifest already carries each entry's resolved permalink, so seed from the
42
40
  // synchronous main-only index and union the branch arm on top.
43
41
  const index = mainAddressIndex(manifest);
44
42
  // The branch arm: read each open cairn/* branch's one edited file and resolve its permalink. The
45
43
  // path is derivable from the branch name, so no tree-listing is needed.
46
- const names = await listBranches(repo, PENDING_PREFIX, token);
44
+ const names = await backend.listBranches(PENDING_PREFIX);
47
45
  const perBranch = await Promise.all(names.map(async (name) => {
48
46
  // Resolve the branch name with the branch tooling's guard: a malformed name, an id that fails
49
47
  // the slug rule, or an unconfigured concept is skipped with no read attempted.
@@ -55,7 +53,7 @@ export async function buildAddressIndex(repo, token, concepts, manifest) {
55
53
  return null;
56
54
  const path = `${concept.dir}/${filenameFromId(ref.id)}`;
57
55
  try {
58
- const raw = await readRaw({ ...repo, branch: name }, path, token);
56
+ const raw = await backend.readFile(path, name);
59
57
  if (raw === null)
60
58
  return null; // The file is absent on the branch: nothing to resolve.
61
59
  const { frontmatter } = parseMarkdown(raw);
@@ -1,9 +1,9 @@
1
1
  import type { CairnAdapter, CairnExtension, CairnRuntime } from './types.js';
2
2
  import { type SiteConfig } from '../nav/site-config.js';
3
3
  /**
4
- * The input to {@link composeRuntime}. `siteConfig` is required so the per-concept URL policy is
5
- * always derived from one source and can never be silently dropped. `extensions` fold in after the
6
- * adapter's concepts.
4
+ * The input to {@link composeRuntime}. `siteConfig` is required: it is the canonical home for the
5
+ * site name, the spellcheck dialect, and the tidy block, so they can never be silently dropped.
6
+ * `extensions` fold in after the adapter's concepts.
7
7
  */
8
8
  export interface ComposeInput {
9
9
  adapter: CairnAdapter;
@@ -11,9 +11,10 @@ export interface ComposeInput {
11
11
  extensions?: CairnExtension[];
12
12
  }
13
13
  /**
14
- * Fold an adapter and any extensions into the composed runtime (seam 2). The per-concept URL policy
15
- * is derived from the site config, the same source the delivery path uses, so the runtime and
16
- * delivery permalinks cannot diverge. Extension concepts merge after the adapter's. The asset slot
14
+ * Fold an adapter and any extensions into the composed runtime (seam 2). This is the one place the
15
+ * grouped adapter maps onto the flat runtime, and the one place the internal manifest and dictionary
16
+ * paths default by convention. Each concept declares its own routing and URL policy, so the runtime
17
+ * and delivery permalinks cannot diverge. Extension concepts merge after the adapter's. The media slot
17
18
  * (seam 4) passes through untouched.
18
19
  */
19
20
  export declare function composeRuntime({ adapter, siteConfig, extensions }: ComposeInput): CairnRuntime;
@@ -1,15 +1,23 @@
1
1
  import { resolveConcepts } from './concepts.js';
2
2
  import { normalizeAssets } from '../media/config.js';
3
3
  import { dictionaryFileForDialect } from '../nav/site-config.js';
4
+ // The internal artifact paths the adapter does not carry. They share the `.cairn/` content root the
5
+ // manifests use, so `composeRuntime` defaults them by convention rather than reading them off config.
6
+ // The personal dictionary sits beside the manifests, so the spec's `content/.cairn/dictionary.txt`
7
+ // resolves the same configurable way the manifest paths do.
8
+ const CONTENT_MANIFEST_PATH = 'src/content/.cairn/index.json';
9
+ const MEDIA_MANIFEST_PATH = 'src/content/.cairn/media.json';
10
+ const DICTIONARY_PATH = 'src/content/.cairn/dictionary.txt';
4
11
  /**
5
- * Fold an adapter and any extensions into the composed runtime (seam 2). The per-concept URL policy
6
- * is derived from the site config, the same source the delivery path uses, so the runtime and
7
- * delivery permalinks cannot diverge. Extension concepts merge after the adapter's. The asset slot
12
+ * Fold an adapter and any extensions into the composed runtime (seam 2). This is the one place the
13
+ * grouped adapter maps onto the flat runtime, and the one place the internal manifest and dictionary
14
+ * paths default by convention. Each concept declares its own routing and URL policy, so the runtime
15
+ * and delivery permalinks cannot diverge. Extension concepts merge after the adapter's. The media slot
8
16
  * (seam 4) passes through untouched.
9
17
  */
10
18
  export function composeRuntime({ adapter, siteConfig, extensions = [] }) {
11
19
  if (!siteConfig)
12
- throw new Error('composeRuntime needs a site config to derive the URL policy');
20
+ throw new Error('composeRuntime needs a site config for the site name and editor settings');
13
21
  const content = { ...adapter.content };
14
22
  const adminPanels = [];
15
23
  const fieldTypes = [];
@@ -24,23 +32,21 @@ export function composeRuntime({ adapter, siteConfig, extensions = [] }) {
24
32
  fieldTypes.push(...extension.fieldTypes);
25
33
  }
26
34
  return {
27
- siteName: adapter.siteName,
28
- concepts: resolveConcepts(content, siteConfig),
35
+ siteName: siteConfig.siteName,
36
+ concepts: resolveConcepts(content),
29
37
  backend: adapter.backend,
30
- sender: adapter.sender,
31
- supportContact: adapter.supportContact,
32
- render: adapter.render,
33
- manifestPath: adapter.manifestPath ?? 'src/content/.cairn/index.json',
34
- registry: adapter.registry,
35
- icons: adapter.icons,
36
- navMenu: adapter.navMenu,
37
- preview: adapter.preview,
38
- assets: adapter.assets,
39
- resolvedAssets: normalizeAssets(adapter.assets),
40
- mediaManifestPath: adapter.mediaManifestPath ?? 'src/content/.cairn/media.json',
41
- // The personal dictionary sits beside the manifests under the same `.cairn/` content root, so the
42
- // spec's `content/.cairn/dictionary.txt` resolves the same configurable way the manifest paths do.
43
- dictionaryPath: adapter.dictionaryPath ?? 'src/content/.cairn/dictionary.txt',
38
+ sender: adapter.email,
39
+ supportContact: adapter.editor?.supportContact,
40
+ render: adapter.rendering.render,
41
+ manifestPath: CONTENT_MANIFEST_PATH,
42
+ registry: adapter.rendering.components,
43
+ icons: adapter.rendering.icons,
44
+ navMenu: adapter.editor?.nav,
45
+ preview: adapter.editor?.preview,
46
+ assets: adapter.media,
47
+ resolvedAssets: normalizeAssets(adapter.media),
48
+ mediaManifestPath: MEDIA_MANIFEST_PATH,
49
+ dictionaryPath: DICTIONARY_PATH,
44
50
  // The spellcheck dictionary is resolved once here from the site config's dialect (default US),
45
51
  // so the runtime and the editor never re-derive it. The site config is the one home for the
46
52
  // dialect; the editor resolves this filename to a real asset URL on the main thread.