@glw907/cairn-cms 0.62.2 → 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 (196) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/auth/types.d.ts +7 -0
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  5. package/dist/components/ComponentForm.svelte +44 -27
  6. package/dist/components/ComponentInsertDialog.svelte +22 -11
  7. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  8. package/dist/components/ConceptList.svelte +25 -4
  9. package/dist/components/EditPage.svelte +29 -107
  10. package/dist/components/EditPage.svelte.d.ts +2 -7
  11. package/dist/components/EntryPicker.svelte +117 -0
  12. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  13. package/dist/components/FieldInput.svelte +218 -0
  14. package/dist/components/FieldInput.svelte.d.ts +51 -0
  15. package/dist/components/IconPicker.svelte +2 -2
  16. package/dist/components/IconPicker.svelte.d.ts +2 -0
  17. package/dist/components/LinkPicker.svelte +8 -75
  18. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  19. package/dist/components/MediaHeroField.svelte +8 -5
  20. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  21. package/dist/components/ObjectGroupField.svelte +54 -0
  22. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  23. package/dist/components/ReferenceField.svelte +94 -0
  24. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  25. package/dist/components/RepeatableField.svelte +221 -0
  26. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  27. package/dist/components/cairn-admin.css +179 -2
  28. package/dist/components/preview-doc.js +5 -1
  29. package/dist/components/tidy-validate.js +1 -1
  30. package/dist/content/adapter.js +18 -0
  31. package/dist/content/advisories.d.ts +2 -2
  32. package/dist/content/advisories.js +3 -5
  33. package/dist/content/compose.d.ts +7 -6
  34. package/dist/content/compose.js +26 -20
  35. package/dist/content/concepts.d.ts +21 -15
  36. package/dist/content/concepts.js +55 -32
  37. package/dist/content/field-rules.d.ts +15 -0
  38. package/dist/content/field-rules.js +38 -0
  39. package/dist/content/fields.d.ts +169 -0
  40. package/dist/content/fields.js +41 -0
  41. package/dist/content/fieldset.d.ts +107 -0
  42. package/dist/content/fieldset.js +386 -0
  43. package/dist/content/frontmatter-region.d.ts +38 -0
  44. package/dist/content/frontmatter-region.js +75 -0
  45. package/dist/content/frontmatter.d.ts +35 -2
  46. package/dist/content/frontmatter.js +232 -11
  47. package/dist/content/manifest.d.ts +34 -0
  48. package/dist/content/manifest.js +80 -4
  49. package/dist/content/media-refs.d.ts +2 -2
  50. package/dist/content/media-rewrite.js +1 -69
  51. package/dist/content/reference-index.d.ts +56 -0
  52. package/dist/content/reference-index.js +95 -0
  53. package/dist/content/references.d.ts +40 -0
  54. package/dist/content/references.js +0 -0
  55. package/dist/content/standard-schema.d.ts +30 -0
  56. package/dist/content/standard-schema.js +4 -0
  57. package/dist/content/types.d.ts +127 -178
  58. package/dist/delivery/data.d.ts +2 -2
  59. package/dist/delivery/data.js +1 -1
  60. package/dist/delivery/public-routes.d.ts +10 -5
  61. package/dist/delivery/public-routes.js +25 -2
  62. package/dist/delivery/site-descriptors.d.ts +5 -1
  63. package/dist/delivery/site-descriptors.js +8 -3
  64. package/dist/delivery/site-indexes.d.ts +2 -2
  65. package/dist/delivery/site-resolver.d.ts +25 -0
  66. package/dist/delivery/site-resolver.js +49 -0
  67. package/dist/doctor/checks-local.js +6 -11
  68. package/dist/github/backend.d.ts +83 -0
  69. package/dist/github/backend.js +76 -0
  70. package/dist/github/credentials.d.ts +11 -5
  71. package/dist/github/credentials.js +3 -3
  72. package/dist/github/repo.d.ts +8 -19
  73. package/dist/github/repo.js +69 -80
  74. package/dist/github/types.d.ts +1 -1
  75. package/dist/github/types.js +4 -4
  76. package/dist/index.d.ts +18 -10
  77. package/dist/index.js +9 -5
  78. package/dist/islands/index.d.ts +12 -0
  79. package/dist/islands/index.js +83 -0
  80. package/dist/islands/types.d.ts +7 -0
  81. package/dist/islands/types.js +1 -0
  82. package/dist/log/events.d.ts +1 -1
  83. package/dist/media/index.d.ts +1 -1
  84. package/dist/media/index.js +1 -1
  85. package/dist/media/manifest.d.ts +11 -0
  86. package/dist/media/manifest.js +13 -0
  87. package/dist/media/rewrite-plan.d.ts +2 -3
  88. package/dist/media/rewrite-plan.js +2 -3
  89. package/dist/media/usage.d.ts +2 -2
  90. package/dist/media/usage.js +3 -5
  91. package/dist/nav/site-config.d.ts +0 -6
  92. package/dist/nav/site-config.js +6 -4
  93. package/dist/render/component-grammar.js +11 -11
  94. package/dist/render/component-reference.js +5 -3
  95. package/dist/render/component-validate.d.ts +4 -1
  96. package/dist/render/component-validate.js +10 -35
  97. package/dist/render/highlight.d.ts +9 -0
  98. package/dist/render/highlight.js +206 -0
  99. package/dist/render/pipeline.d.ts +0 -6
  100. package/dist/render/pipeline.js +13 -2
  101. package/dist/render/registry.d.ts +44 -36
  102. package/dist/render/registry.js +47 -6
  103. package/dist/render/rehype-dispatch.d.ts +6 -10
  104. package/dist/render/rehype-dispatch.js +38 -17
  105. package/dist/render/remark-directives.js +4 -5
  106. package/dist/render/sanitize-schema.d.ts +10 -0
  107. package/dist/render/sanitize-schema.js +30 -1
  108. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  109. package/dist/sveltekit/cairn-admin.js +3 -4
  110. package/dist/sveltekit/content-routes.d.ts +10 -8
  111. package/dist/sveltekit/content-routes.js +269 -181
  112. package/dist/sveltekit/guard.js +10 -0
  113. package/dist/sveltekit/health.d.ts +7 -3
  114. package/dist/sveltekit/health.js +9 -3
  115. package/dist/sveltekit/index.d.ts +1 -1
  116. package/dist/sveltekit/nav-routes.d.ts +6 -5
  117. package/dist/sveltekit/nav-routes.js +22 -20
  118. package/dist/sveltekit/types.d.ts +2 -0
  119. package/dist/vite/index.d.ts +3 -3
  120. package/dist/vite/index.js +17 -8
  121. package/package.json +17 -2
  122. package/src/lib/ambient.ts +7 -0
  123. package/src/lib/auth/types.ts +7 -0
  124. package/src/lib/components/CairnAdmin.svelte +2 -6
  125. package/src/lib/components/ComponentForm.svelte +48 -27
  126. package/src/lib/components/ComponentInsertDialog.svelte +26 -14
  127. package/src/lib/components/ConceptList.svelte +41 -4
  128. package/src/lib/components/EditPage.svelte +43 -119
  129. package/src/lib/components/EntryPicker.svelte +154 -0
  130. package/src/lib/components/FieldInput.svelte +262 -0
  131. package/src/lib/components/IconPicker.svelte +4 -2
  132. package/src/lib/components/LinkPicker.svelte +10 -81
  133. package/src/lib/components/MediaHeroField.svelte +12 -5
  134. package/src/lib/components/ObjectGroupField.svelte +97 -0
  135. package/src/lib/components/ReferenceField.svelte +126 -0
  136. package/src/lib/components/RepeatableField.svelte +310 -0
  137. package/src/lib/components/preview-doc.ts +5 -1
  138. package/src/lib/components/tidy-validate.ts +1 -1
  139. package/src/lib/content/adapter.ts +21 -0
  140. package/src/lib/content/advisories.ts +4 -7
  141. package/src/lib/content/compose.ts +30 -23
  142. package/src/lib/content/concepts.ts +68 -40
  143. package/src/lib/content/field-rules.ts +39 -0
  144. package/src/lib/content/fields.ts +178 -0
  145. package/src/lib/content/fieldset.ts +470 -0
  146. package/src/lib/content/frontmatter-region.ts +90 -0
  147. package/src/lib/content/frontmatter.ts +231 -15
  148. package/src/lib/content/manifest.ts +101 -4
  149. package/src/lib/content/media-refs.ts +2 -2
  150. package/src/lib/content/media-rewrite.ts +7 -80
  151. package/src/lib/content/reference-index.ts +159 -0
  152. package/src/lib/content/references.ts +0 -0
  153. package/src/lib/content/standard-schema.ts +25 -0
  154. package/src/lib/content/types.ts +128 -195
  155. package/src/lib/delivery/data.ts +2 -2
  156. package/src/lib/delivery/public-routes.ts +36 -4
  157. package/src/lib/delivery/site-descriptors.ts +8 -3
  158. package/src/lib/delivery/site-indexes.ts +2 -2
  159. package/src/lib/delivery/site-resolver.ts +64 -0
  160. package/src/lib/doctor/checks-local.ts +6 -14
  161. package/src/lib/github/backend.ts +161 -0
  162. package/src/lib/github/credentials.ts +10 -7
  163. package/src/lib/github/repo.ts +79 -83
  164. package/src/lib/github/types.ts +5 -5
  165. package/src/lib/index.ts +40 -18
  166. package/src/lib/islands/index.ts +84 -0
  167. package/src/lib/islands/types.ts +11 -0
  168. package/src/lib/log/events.ts +1 -0
  169. package/src/lib/media/index.ts +1 -0
  170. package/src/lib/media/manifest.ts +14 -0
  171. package/src/lib/media/rewrite-plan.ts +4 -6
  172. package/src/lib/media/usage.ts +4 -7
  173. package/src/lib/nav/site-config.ts +8 -9
  174. package/src/lib/render/component-grammar.ts +10 -10
  175. package/src/lib/render/component-reference.ts +4 -3
  176. package/src/lib/render/component-validate.ts +10 -35
  177. package/src/lib/render/highlight.ts +259 -0
  178. package/src/lib/render/pipeline.ts +13 -8
  179. package/src/lib/render/registry.ts +88 -42
  180. package/src/lib/render/rehype-dispatch.ts +47 -16
  181. package/src/lib/render/remark-directives.ts +4 -5
  182. package/src/lib/render/sanitize-schema.ts +32 -1
  183. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  184. package/src/lib/sveltekit/content-routes.ts +330 -221
  185. package/src/lib/sveltekit/guard.ts +15 -0
  186. package/src/lib/sveltekit/health.ts +13 -6
  187. package/src/lib/sveltekit/index.ts +2 -2
  188. package/src/lib/sveltekit/nav-routes.ts +33 -29
  189. package/src/lib/sveltekit/types.ts +5 -1
  190. package/src/lib/vite/index.ts +20 -11
  191. package/dist/content/schema.d.ts +0 -87
  192. package/dist/content/schema.js +0 -89
  193. package/dist/content/validate.d.ts +0 -17
  194. package/dist/content/validate.js +0 -93
  195. package/src/lib/content/schema.ts +0 -167
  196. package/src/lib/content/validate.ts +0 -90
@@ -1,45 +1,19 @@
1
1
  <!--
2
2
  @component
3
- The "Link to page" control and its modal. It lists the site's posts and pages from the committed
4
- manifest (the linkTargets the editor receives), grouped by concept with Pages first, each post
5
- showing its date and each draft marked. Picking a target inserts a cairn: internal link through the
6
- editor's registerInsertLink seam. Built on a native <dialog>, following the component dialog's a11y
7
- conventions. The plain-URL link is the toolbar's Web link dialog; this is for an internal target.
3
+ The "Link to page" control. It wraps EntryPicker, the shared search + concept-grouped target list, and
4
+ turns the chosen target into an inline cairn: internal link through the editor's registerInsertLink
5
+ seam. EntryPicker owns the list and search; this component owns the cairn-token meaning and the trigger.
6
+ The plain-URL link is the toolbar's Web link dialog; this is for an internal target.
8
7
  -->
9
8
  <script lang="ts">import { formatCairnToken } from "../content/links.js";
9
+ import EntryPicker from "./EntryPicker.svelte";
10
10
  let { linkTargets, insert, disabled = false, trigger = true } = $props();
11
- let dialog = $state(null);
12
- let query = $state("");
13
- const ORDER = { pages: 0, posts: 1 };
14
- function rank(concept) {
15
- return ORDER[concept] ?? 2;
16
- }
17
- function heading(concept) {
18
- if (concept === "pages") return "Pages";
19
- if (concept === "posts") return "Posts";
20
- return concept.charAt(0).toUpperCase() + concept.slice(1);
21
- }
22
- const groups = $derived.by(() => {
23
- const q = query.trim().toLowerCase();
24
- const matched = q ? linkTargets.filter((t) => t.title.toLowerCase().includes(q)) : linkTargets;
25
- const byConcept = /* @__PURE__ */ new Map();
26
- for (const t of matched) {
27
- const list = byConcept.get(t.concept) ?? [];
28
- list.push(t);
29
- byConcept.set(t.concept, list);
30
- }
31
- return [...byConcept.entries()].map(([concept, items]) => ({ concept, heading: heading(concept), items })).sort((a, b) => rank(a.concept) - rank(b.concept) || a.heading.localeCompare(b.heading));
32
- });
11
+ let picker = $state(null);
33
12
  export function open() {
34
- query = "";
35
- dialog?.showModal();
36
- }
37
- function close() {
38
- dialog?.close();
13
+ picker?.open();
39
14
  }
40
15
  function choose(target) {
41
16
  insert(formatCairnToken(target), target.title);
42
- close();
43
17
  }
44
18
  </script>
45
19
 
@@ -49,45 +23,4 @@ function choose(target) {
49
23
  </button>
50
24
  {/if}
51
25
 
52
- <dialog class="modal" aria-labelledby="cairn-link-dialog-title" bind:this={dialog}>
53
- <div class="modal-box">
54
- <div class="mb-3 flex items-center justify-between">
55
- <h2 id="cairn-link-dialog-title" class="text-base font-semibold">Link to a page</h2>
56
- <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
57
- </div>
58
-
59
- <input
60
- type="search"
61
- class="input input-bordered mb-3 w-full"
62
- placeholder="Search by title"
63
- aria-label="Search pages and posts"
64
- bind:value={query}
65
- />
66
-
67
- {#if groups.length === 0}
68
- <p class="text-sm text-[var(--color-muted)]">No pages or posts to link to.</p>
69
- {:else}
70
- {#each groups as group (group.concept)}
71
- <h3 class="mt-2 mb-1 text-xs font-semibold tracking-wide text-[var(--color-muted)] uppercase">{group.heading}</h3>
72
- <ul class="menu w-full">
73
- {#each group.items as target (`${target.concept}/${target.id}`)}
74
- <li>
75
- <button type="button" onclick={() => choose(target)}>
76
- <span class="flex flex-col items-start">
77
- <span class="font-medium">{target.title}</span>
78
- <span class="text-xs text-[var(--color-muted)]">
79
- {#if target.draft}<span class="badge badge-ghost badge-sm mr-1">Draft</span>{/if}
80
- {#if target.date}{target.date}{/if}
81
- </span>
82
- </span>
83
- </button>
84
- </li>
85
- {/each}
86
- </ul>
87
- {/each}
88
- {/if}
89
- </div>
90
- <form method="dialog" class="modal-backdrop">
91
- <button tabindex="-1" aria-label="Close">close</button>
92
- </form>
93
- </dialog>
26
+ <EntryPicker bind:this={picker} targets={linkTargets} {choose} trigger={false} />
@@ -11,11 +11,10 @@ interface Props {
11
11
  trigger?: boolean;
12
12
  }
13
13
  /**
14
- * The "Link to page" control and its modal. It lists the site's posts and pages from the committed
15
- * manifest (the linkTargets the editor receives), grouped by concept with Pages first, each post
16
- * showing its date and each draft marked. Picking a target inserts a cairn: internal link through the
17
- * editor's registerInsertLink seam. Built on a native <dialog>, following the component dialog's a11y
18
- * conventions. The plain-URL link is the toolbar's Web link dialog; this is for an internal target.
14
+ * The "Link to page" control. It wraps EntryPicker, the shared search + concept-grouped target list, and
15
+ * turns the chosen target into an inline cairn: internal link through the editor's registerInsertLink
16
+ * seam. EntryPicker owns the list and search; this component owns the cairn-token meaning and the trigger.
17
+ * The plain-URL link is the toolbar's Web link dialog; this is for an internal target.
19
18
  */
20
19
  declare const LinkPicker: import("svelte").Component<Props, {
21
20
  open: () => void;
@@ -53,6 +53,7 @@ let {
53
53
  field,
54
54
  value,
55
55
  decorative: decorativeInitial = false,
56
+ lead = false,
56
57
  mediaLibrary,
57
58
  conceptId,
58
59
  id,
@@ -323,13 +324,15 @@ function onDropzoneDragover(e) {
323
324
  <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /><circle cx="9" cy="9" r="2" /><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" /></svg>
324
325
  </span>
325
326
  <span class="flex min-w-0 flex-col gap-px">
326
- <span class="text-[0.8125rem] font-medium">Add a hero image</span>
327
+ <span class="text-[0.8125rem] font-medium">Add {field.label.toLowerCase()}</span>
327
328
  <span class="text-[0.6875rem] text-[var(--color-muted)]">Drop an image here, or pick from the library</span>
328
329
  </span>
329
330
  </button>
330
- <p class="text-[0.6875rem] leading-snug text-[var(--color-muted)]">
331
- This image leads the page, and it is the picture shown when the post is shared.
332
- </p>
331
+ {#if lead}
332
+ <p class="text-[0.6875rem] leading-snug text-[var(--color-muted)]">
333
+ This image leads the page, and it is the picture shown when the post is shared.
334
+ </p>
335
+ {/if}
333
336
  {/if}
334
337
 
335
338
  <!-- The committed value rides four named hidden inputs the save path's decode arm reads. They sit
@@ -349,7 +352,7 @@ function onDropzoneDragover(e) {
349
352
  <div class="modal-box max-w-md">
350
353
  <div class="mb-3 flex items-center justify-between gap-2">
351
354
  <h2 id={titleId} class="text-[0.9375rem] font-semibold">
352
- {view === 'chooser' ? 'Add a hero image' : 'Hero image'}
355
+ {view === 'chooser' ? `Add ${field.label.toLowerCase()}` : field.label}
353
356
  </h2>
354
357
  <button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Close" onclick={closeDialog}>
355
358
  <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
@@ -16,6 +16,10 @@ interface Props {
16
16
  /** Whether the initial hero is an explicit decorative choice (an empty alt that is not debt).
17
17
  * Defaults false; a fresh field with an empty alt reads as needs-alt. */
18
18
  decorative?: boolean;
19
+ /** Whether this image leads the page and becomes the social card (a top-level SEO hero). When
20
+ * false (a gallery or nested image item), the empty state drops the hero-only social-card line
21
+ * and the copy reads neutrally. Defaults false. */
22
+ lead?: boolean;
19
23
  /** The merged committed-plus-uploaded media library, keyed by content hash. */
20
24
  mediaLibrary: Record<string, MediaLibraryEntry>;
21
25
  /** The concept the entry belongs to (the upload action's route param). */
@@ -0,0 +1,54 @@
1
+ <!--
2
+ @component
3
+ A labeled group of leaf fields, the editor arm for an `object` container. It renders each leaf one
4
+ level down through `FieldInput`, prefixing the leaf's form name with this group's name
5
+ (`${name}.${leafKey}`) so the nested decode in `frontmatter.ts` reads each value back into the right
6
+ sub-key. The group's value slice (the object, defaulting to `{}`) is passed straight through as the
7
+ leaf dispatcher's `frontmatter`, so a leaf reads `frontmatter[leafKey]` unchanged.
8
+
9
+ The legend uses the object's optional `label`; when the schema omits it (an object inside an array is
10
+ labeled by the array, so it carries no label of its own), a humanized field key stands in. The group
11
+ matches the Details fieldset/legend recipe so it reads as one of the panel's grouped sections.
12
+ -->
13
+ <script lang="ts">import FieldInput from "./FieldInput.svelte";
14
+ let {
15
+ field,
16
+ name,
17
+ frontmatter,
18
+ targets,
19
+ markFieldsDirty,
20
+ mediaLibrary,
21
+ conceptId,
22
+ id,
23
+ heroFieldRefs,
24
+ onuploaded,
25
+ onheroneedsalt,
26
+ icons
27
+ } = $props();
28
+ function humanize(key) {
29
+ const words = key.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim();
30
+ return words.charAt(0).toUpperCase() + words.slice(1);
31
+ }
32
+ const legend = $derived(field.label ?? humanize(field.name));
33
+ const leaves = $derived(Object.entries(field.fields));
34
+ </script>
35
+
36
+ <fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
37
+ <legend class="text-sm font-medium">{legend}</legend>
38
+ {#each leaves as [leafKey, leaf] (leafKey)}
39
+ <FieldInput
40
+ field={{ ...leaf, name: leafKey }}
41
+ name={`${name}.${leafKey}`}
42
+ {frontmatter}
43
+ {targets}
44
+ {markFieldsDirty}
45
+ {mediaLibrary}
46
+ {conceptId}
47
+ {id}
48
+ {heroFieldRefs}
49
+ {onuploaded}
50
+ {onheroneedsalt}
51
+ {icons}
52
+ />
53
+ {/each}
54
+ </fieldset>
@@ -0,0 +1,47 @@
1
+ import type { NamedField } from '../content/types.js';
2
+ import type { ObjectField } 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 object descriptor to render; its `fields` are the leaves this group holds. */
10
+ field: NamedField & ObjectField;
11
+ /** The form name prefix for this group; each leaf renders at `${name}.${leafKey}`. */
12
+ name: string;
13
+ /** The object value slice this group reads from, keyed by leaf sub-key. */
14
+ frontmatter: Record<string, unknown>;
15
+ /** The site link targets the reference arm offers (threaded through to each leaf). */
16
+ targets: LinkTarget[];
17
+ /** Mark the edit form dirty; threaded to each leaf's media or reference arm. */
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 groups 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 leaf's icon arm. */
32
+ icons?: IconSet;
33
+ }
34
+ /**
35
+ * A labeled group of leaf fields, the editor arm for an `object` container. It renders each leaf one
36
+ * level down through `FieldInput`, prefixing the leaf's form name with this group's name
37
+ * (`${name}.${leafKey}`) so the nested decode in `frontmatter.ts` reads each value back into the right
38
+ * sub-key. The group's value slice (the object, defaulting to `{}`) is passed straight through as the
39
+ * leaf dispatcher's `frontmatter`, so a leaf reads `frontmatter[leafKey]` unchanged.
40
+ *
41
+ * The legend uses the object's optional `label`; when the schema omits it (an object inside an array is
42
+ * labeled by the array, so it carries no label of its own), a humanized field key stands in. The group
43
+ * matches the Details fieldset/legend recipe so it reads as one of the panel's grouped sections.
44
+ */
45
+ declare const ObjectGroupField: import("svelte").Component<Props, {}, "">;
46
+ type ObjectGroupField = ReturnType<typeof ObjectGroupField>;
47
+ export default ObjectGroupField;
@@ -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>