@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
@@ -21,9 +21,11 @@ IconSet path data, matching the renderer's 256-unit viewBox.
21
21
  onChange: (name: string) => void;
22
22
  /** The group's accessible name, threaded from the field label. Defaults to Icon. */
23
23
  label?: string;
24
+ /** The id of a describing element (a field hint), for the radiogroup's `aria-describedby`. */
25
+ describedby?: string;
24
26
  }
25
27
 
26
- let { icons, value, required, onChange, label = 'Icon' }: Props = $props();
28
+ let { icons, value, required, onChange, label = 'Icon', describedby }: Props = $props();
27
29
 
28
30
  // The radiogroup container, used to move focus with the selection per the ARIA radiogroup pattern.
29
31
  let group: HTMLDivElement;
@@ -62,7 +64,7 @@ IconSet path data, matching the renderer's 256-unit viewBox.
62
64
  }
63
65
  </script>
64
66
 
65
- <div class="flex flex-wrap gap-2" role="radiogroup" aria-label={label} bind:this={group}>
67
+ <div class="flex flex-wrap gap-2" role="radiogroup" aria-label={label} aria-required={required ? 'true' : undefined} aria-describedby={describedby} bind:this={group}>
66
68
  {#if !required}
67
69
  <button
68
70
  type="button"
@@ -1,14 +1,14 @@
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">
10
9
  import type { LinkTarget } from '../content/manifest.js';
11
10
  import { formatCairnToken } from '../content/links.js';
11
+ import EntryPicker from './EntryPicker.svelte';
12
12
 
13
13
  interface Props {
14
14
  /** The site's link targets, from the committed manifest (editLoad ships them). */
@@ -24,46 +24,16 @@ conventions. The plain-URL link is the toolbar's Web link dialog; this is for an
24
24
 
25
25
  let { linkTargets, insert, disabled = false, trigger = true }: Props = $props();
26
26
 
27
- let dialog = $state<HTMLDialogElement | null>(null);
28
- let query = $state('');
29
-
30
- // Group filtered targets by concept, Pages first then Posts then any other concept, so the list
31
- // reads in a stable order. The filter is a case-insensitive title substring.
32
- const ORDER: Record<string, number> = { pages: 0, posts: 1 };
33
- function rank(concept: string): number {
34
- return ORDER[concept] ?? 2;
35
- }
36
- function heading(concept: string): string {
37
- if (concept === 'pages') return 'Pages';
38
- if (concept === 'posts') return 'Posts';
39
- return concept.charAt(0).toUpperCase() + concept.slice(1);
40
- }
41
-
42
- const groups = $derived.by(() => {
43
- const q = query.trim().toLowerCase();
44
- const matched = q ? linkTargets.filter((t) => t.title.toLowerCase().includes(q)) : linkTargets;
45
- const byConcept = new Map<string, LinkTarget[]>();
46
- for (const t of matched) {
47
- const list = byConcept.get(t.concept) ?? [];
48
- list.push(t);
49
- byConcept.set(t.concept, list);
50
- }
51
- return [...byConcept.entries()]
52
- .map(([concept, items]) => ({ concept, heading: heading(concept), items }))
53
- .sort((a, b) => rank(a.concept) - rank(b.concept) || a.heading.localeCompare(b.heading));
54
- });
27
+ // The headless picker; this component drives it so it can carry the disabled trigger and the
28
+ // cairn-token meaning, while EntryPicker stays ignorant of both.
29
+ let picker = $state<{ open: () => void } | null>(null);
55
30
 
56
31
  /** Open the picker programmatically, for a host that drives it without the trigger. */
57
32
  export function open() {
58
- query = '';
59
- dialog?.showModal();
60
- }
61
- function close() {
62
- dialog?.close();
33
+ picker?.open();
63
34
  }
64
35
  function choose(target: LinkTarget) {
65
36
  insert(formatCairnToken(target), target.title);
66
- close();
67
37
  }
68
38
  </script>
69
39
 
@@ -73,45 +43,4 @@ conventions. The plain-URL link is the toolbar's Web link dialog; this is for an
73
43
  </button>
74
44
  {/if}
75
45
 
76
- <dialog class="modal" aria-labelledby="cairn-link-dialog-title" bind:this={dialog}>
77
- <div class="modal-box">
78
- <div class="mb-3 flex items-center justify-between">
79
- <h2 id="cairn-link-dialog-title" class="text-base font-semibold">Link to a page</h2>
80
- <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
81
- </div>
82
-
83
- <input
84
- type="search"
85
- class="input input-bordered mb-3 w-full"
86
- placeholder="Search by title"
87
- aria-label="Search pages and posts"
88
- bind:value={query}
89
- />
90
-
91
- {#if groups.length === 0}
92
- <p class="text-sm text-[var(--color-muted)]">No pages or posts to link to.</p>
93
- {:else}
94
- {#each groups as group (group.concept)}
95
- <h3 class="mt-2 mb-1 text-xs font-semibold tracking-wide text-[var(--color-muted)] uppercase">{group.heading}</h3>
96
- <ul class="menu w-full">
97
- {#each group.items as target (`${target.concept}/${target.id}`)}
98
- <li>
99
- <button type="button" onclick={() => choose(target)}>
100
- <span class="flex flex-col items-start">
101
- <span class="font-medium">{target.title}</span>
102
- <span class="text-xs text-[var(--color-muted)]">
103
- {#if target.draft}<span class="badge badge-ghost badge-sm mr-1">Draft</span>{/if}
104
- {#if target.date}{target.date}{/if}
105
- </span>
106
- </span>
107
- </button>
108
- </li>
109
- {/each}
110
- </ul>
111
- {/each}
112
- {/if}
113
- </div>
114
- <form method="dialog" class="modal-backdrop">
115
- <button tabindex="-1" aria-label="Close">close</button>
116
- </form>
117
- </dialog>
46
+ <EntryPicker bind:this={picker} targets={linkTargets} {choose} trigger={false} />
@@ -60,6 +60,10 @@ popover's runUpload but resolves to this field, not an editor placeholder.
60
60
  /** Whether the initial hero is an explicit decorative choice (an empty alt that is not debt).
61
61
  * Defaults false; a fresh field with an empty alt reads as needs-alt. */
62
62
  decorative?: boolean;
63
+ /** Whether this image leads the page and becomes the social card (a top-level SEO hero). When
64
+ * false (a gallery or nested image item), the empty state drops the hero-only social-card line
65
+ * and the copy reads neutrally. Defaults false. */
66
+ lead?: boolean;
63
67
  /** The merged committed-plus-uploaded media library, keyed by content hash. */
64
68
  mediaLibrary: Record<string, MediaLibraryEntry>;
65
69
  /** The concept the entry belongs to (the upload action's route param). */
@@ -83,6 +87,7 @@ popover's runUpload but resolves to this field, not an editor placeholder.
83
87
  field,
84
88
  value,
85
89
  decorative: decorativeInitial = false,
90
+ lead = false,
86
91
  mediaLibrary,
87
92
  conceptId,
88
93
  id,
@@ -442,13 +447,15 @@ popover's runUpload but resolves to this field, not an editor placeholder.
442
447
  <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>
443
448
  </span>
444
449
  <span class="flex min-w-0 flex-col gap-px">
445
- <span class="text-[0.8125rem] font-medium">Add a hero image</span>
450
+ <span class="text-[0.8125rem] font-medium">Add {field.label.toLowerCase()}</span>
446
451
  <span class="text-[0.6875rem] text-[var(--color-muted)]">Drop an image here, or pick from the library</span>
447
452
  </span>
448
453
  </button>
449
- <p class="text-[0.6875rem] leading-snug text-[var(--color-muted)]">
450
- This image leads the page, and it is the picture shown when the post is shared.
451
- </p>
454
+ {#if lead}
455
+ <p class="text-[0.6875rem] leading-snug text-[var(--color-muted)]">
456
+ This image leads the page, and it is the picture shown when the post is shared.
457
+ </p>
458
+ {/if}
452
459
  {/if}
453
460
 
454
461
  <!-- The committed value rides four named hidden inputs the save path's decode arm reads. They sit
@@ -468,7 +475,7 @@ popover's runUpload but resolves to this field, not an editor placeholder.
468
475
  <div class="modal-box max-w-md">
469
476
  <div class="mb-3 flex items-center justify-between gap-2">
470
477
  <h2 id={titleId} class="text-[0.9375rem] font-semibold">
471
- {view === 'chooser' ? 'Add a hero image' : 'Hero image'}
478
+ {view === 'chooser' ? `Add ${field.label.toLowerCase()}` : field.label}
472
479
  </h2>
473
480
  <button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Close" onclick={closeDialog}>
474
481
  <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>
@@ -0,0 +1,97 @@
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">
14
+ import FieldInput from './FieldInput.svelte';
15
+ import type { NamedField } from '../content/types.js';
16
+ import type { ObjectField } from '../content/fields.js';
17
+ import type { LinkTarget } from '../content/manifest.js';
18
+ import type { MediaEntry } from '../media/manifest.js';
19
+ import type { MediaLibraryEntry } from '../media/library-entry.js';
20
+ import type { IconSet } from '../render/glyph.js';
21
+ import type MediaHeroField from './MediaHeroField.svelte';
22
+
23
+ interface Props {
24
+ /** The object descriptor to render; its `fields` are the leaves this group holds. */
25
+ field: NamedField & ObjectField;
26
+ /** The form name prefix for this group; each leaf renders at `${name}.${leafKey}`. */
27
+ name: string;
28
+ /** The object value slice this group reads from, keyed by leaf sub-key. */
29
+ frontmatter: Record<string, unknown>;
30
+ /** The site link targets the reference arm offers (threaded through to each leaf). */
31
+ targets: LinkTarget[];
32
+ /** Mark the edit form dirty; threaded to each leaf's media or reference arm. */
33
+ markFieldsDirty: () => void;
34
+ /** The merged committed-plus-uploaded media library, keyed by content hash. */
35
+ mediaLibrary: Record<string, MediaLibraryEntry>;
36
+ /** The concept the entry belongs to (the upload action's route param). */
37
+ conceptId: string;
38
+ /** The entry id (the upload action's route param). */
39
+ id: string;
40
+ /** The host's hero-field refs, keyed by the prefixed `name` so two groups do not collide. */
41
+ heroFieldRefs: Record<string, MediaHeroField>;
42
+ /** Called with the server-owned record on a successful upload, so the host merges it. */
43
+ onuploaded: (record: MediaEntry) => void;
44
+ /** Called when a hero's needs-alt status changes, keyed by the prefixed `name`. */
45
+ onheroneedsalt: (name: string, needsAlt: boolean) => void;
46
+ /** The site's icon set, forwarded to each leaf's icon arm. */
47
+ icons?: IconSet;
48
+ }
49
+
50
+ let {
51
+ field,
52
+ name,
53
+ frontmatter,
54
+ targets,
55
+ markFieldsDirty,
56
+ mediaLibrary,
57
+ conceptId,
58
+ id,
59
+ heroFieldRefs,
60
+ onuploaded,
61
+ onheroneedsalt,
62
+ icons,
63
+ }: Props = $props();
64
+
65
+ // Turn a field key into a sentence-case legend when the object carries no label of its own:
66
+ // 'social_card' -> 'Social card', 'ogImage' -> 'Og image'. The label, when set, wins.
67
+ function humanize(key: string): string {
68
+ const words = key
69
+ .replace(/[_-]+/g, ' ')
70
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
71
+ .trim();
72
+ return words.charAt(0).toUpperCase() + words.slice(1);
73
+ }
74
+
75
+ const legend = $derived(field.label ?? humanize(field.name));
76
+ const leaves = $derived(Object.entries(field.fields));
77
+ </script>
78
+
79
+ <fieldset class="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
80
+ <legend class="text-sm font-medium">{legend}</legend>
81
+ {#each leaves as [leafKey, leaf] (leafKey)}
82
+ <FieldInput
83
+ field={{ ...leaf, name: leafKey }}
84
+ name={`${name}.${leafKey}`}
85
+ {frontmatter}
86
+ {targets}
87
+ {markFieldsDirty}
88
+ {mediaLibrary}
89
+ {conceptId}
90
+ {id}
91
+ {heroFieldRefs}
92
+ {onuploaded}
93
+ {onheroneedsalt}
94
+ {icons}
95
+ />
96
+ {/each}
97
+ </fieldset>
@@ -0,0 +1,126 @@
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">
12
+ import { untrack } from 'svelte';
13
+ import type { LinkTarget } from '../content/manifest.js';
14
+ import type { ReferenceField } from '../content/fields.js';
15
+ import type { NamedField } from '../content/types.js';
16
+ import EntryPicker from './EntryPicker.svelte';
17
+
18
+ interface Props {
19
+ /** The reference or array(reference) descriptor this arm renders. */
20
+ field: NamedField;
21
+ /** The current value: one id for a single reference, a list of ids for an array. */
22
+ value: string | string[];
23
+ /** The site's link targets, from the committed manifest (editLoad ships them). */
24
+ targets: LinkTarget[];
25
+ /** Called when the committed ids change (a pick, an add, or a remove), so the host sets
26
+ * fieldsDirty. The hidden-input writes do not fire the form's oninput, so the field signals
27
+ * dirty explicitly, the same way MediaHeroField does. */
28
+ ondirty?: () => void;
29
+ }
30
+
31
+ let { field, value, targets, ondirty }: Props = $props();
32
+
33
+ // The descriptor's concept, read from the single reference or the array's reference item, so the
34
+ // picker scopes to the right concept and a chip resolves its title within that concept's targets.
35
+ // Narrow on field.type so the access needs no cast; the array item is always a reference here
36
+ // (fieldset's checkArrayItems enforces it at declaration), so the one cast names that guarantee.
37
+ const concept = $derived.by(() => {
38
+ if (field.type === 'array') return (field.item as ReferenceField).concept;
39
+ if (field.type === 'reference') return field.concept;
40
+ return '';
41
+ });
42
+
43
+ // The single reference's current id, seeded once from the prop and owned thereafter (untrack marks
44
+ // the read a deliberate one-time seed, not a reactive dependency). Updated on pick.
45
+ let singleId = $state(untrack(() => (typeof value === 'string' ? value : '')));
46
+ // The array's current ids, seeded once from the prop and updated as chips are added and removed.
47
+ let ids = $state<string[]>(untrack(() => (Array.isArray(value) ? [...value] : [])));
48
+
49
+ // The headless picker; this component drives it so it can carry the concept filter and the held ids.
50
+ let picker = $state<{ open: () => void } | null>(null);
51
+
52
+ /** Resolve an id to its target title within this field's concept, falling back to the bare id. */
53
+ function titleFor(id: string): string {
54
+ return targets.find((t) => t.concept === concept && t.id === id)?.title ?? id;
55
+ }
56
+
57
+ function chooseSingle(target: LinkTarget) {
58
+ singleId = target.id;
59
+ ondirty?.();
60
+ }
61
+ function chooseMany(target: LinkTarget) {
62
+ if (!ids.includes(target.id)) {
63
+ ids = [...ids, target.id];
64
+ ondirty?.();
65
+ }
66
+ }
67
+ function remove(id: string) {
68
+ ids = ids.filter((x) => x !== id);
69
+ ondirty?.();
70
+ }
71
+ </script>
72
+
73
+ {#if field.type === 'array'}
74
+ <fieldset class="m-0 flex min-w-0 flex-col gap-2 border-0 p-0">
75
+ <legend class="text-sm font-medium">{field.label}</legend>
76
+ {#if ids.length}
77
+ <ul class="flex flex-wrap gap-2">
78
+ {#each ids as id (id)}
79
+ <li class="badge badge-ghost gap-1">
80
+ <span>{titleFor(id)}</span>
81
+ <button type="button" class="btn btn-ghost btn-xs btn-square" aria-label={`Remove ${titleFor(id)}`} onclick={() => remove(id)}>
82
+ <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>
83
+ </button>
84
+ <input type="hidden" name={field.name} value={id} />
85
+ </li>
86
+ {/each}
87
+ </ul>
88
+ {/if}
89
+ <div>
90
+ <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label={`Add ${field.label}`} onclick={() => picker?.open()}>
91
+ Add {field.label}
92
+ </button>
93
+ </div>
94
+ </fieldset>
95
+ <EntryPicker
96
+ bind:this={picker}
97
+ {targets}
98
+ choose={chooseMany}
99
+ conceptFilter={concept}
100
+ selectedIds={ids}
101
+ trigger={false}
102
+ heading={`Choose ${field.label}`}
103
+ searchLabel={`Search ${concept}`}
104
+ emptyText={`No ${concept} to choose.`}
105
+ />
106
+ {:else}
107
+ <div class="flex flex-col gap-1">
108
+ <span class="text-sm font-medium">{field.label}</span>
109
+ <button type="button" class="btn btn-sm btn-ghost justify-start" aria-haspopup="dialog" aria-label={field.label} onclick={() => picker?.open()}>
110
+ {#if singleId}{titleFor(singleId)}{:else}<span class="text-[var(--color-muted)]">Choose {field.label}</span>{/if}
111
+ </button>
112
+ {#if singleId}
113
+ <input type="hidden" name={field.name} value={singleId} />
114
+ {/if}
115
+ </div>
116
+ <EntryPicker
117
+ bind:this={picker}
118
+ {targets}
119
+ choose={chooseSingle}
120
+ conceptFilter={concept}
121
+ trigger={false}
122
+ heading={`Choose ${field.label}`}
123
+ searchLabel={`Search ${concept}`}
124
+ emptyText={`No ${concept} to choose.`}
125
+ />
126
+ {/if}