@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
@@ -35,7 +35,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
35
35
  import LinkPicker from './LinkPicker.svelte';
36
36
  import WebLinkDialog from './WebLinkDialog.svelte';
37
37
  import MediaInsertPopover from './MediaInsertPopover.svelte';
38
- import MediaHeroField from './MediaHeroField.svelte';
38
+ import FieldInput from './FieldInput.svelte';
39
+ import type MediaHeroField from './MediaHeroField.svelte';
39
40
  import MediaFigureControl from './MediaFigureControl.svelte';
40
41
  import DeleteDialog from './DeleteDialog.svelte';
41
42
  import RenameDialog from './RenameDialog.svelte';
@@ -62,10 +63,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
62
63
  import { parseComponent, componentRoundTripSafety } from '../render/component-grammar.js';
63
64
  import type { IconSet } from '../render/glyph.js';
64
65
  import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
65
- import type { TextareaField, TagsField, FreeTagsField, ImageValue } from '../content/types.js';
66
- import type { LinkResolve } from '../content/links.js';
66
+ import type { SiteRender } from '../content/types.js';
67
67
  import { manifestLinkResolver } from '../content/manifest.js';
68
- import type { MediaResolve } from '../render/resolve-media.js';
69
68
  import { manifestMediaResolver } from '../render/resolve-media.js';
70
69
  import type { MediaEntry } from '../media/manifest.js';
71
70
  import { mediaLibraryEntry } from '../media/library-entry.js';
@@ -77,10 +76,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
77
76
  /** The site's component registry, for the insert palette. */
78
77
  registry?: ComponentRegistry;
79
78
  /** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
80
- render?: (
81
- md: string,
82
- opts?: { stagger?: boolean; resolve?: LinkResolve; resolveMedia?: MediaResolve },
83
- ) => string | Promise<string>;
79
+ render?: SiteRender;
84
80
  /** The site's icon set, for the guided form's icon fields. */
85
81
  icons?: IconSet;
86
82
  /** The last content action's failure: the save guard's broken links, the delete guard's
@@ -970,7 +966,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
970
966
  // author can act on or leave; the count drops and the notice clears as each alt is filled.
971
967
  const needsAlt = $derived(findMediaImagesNeedingAlt(body));
972
968
 
973
- // The declared image (hero) fields, for labelling the needs-alt notice's frontmatter rows.
969
+ // The declared image (hero) fields, for labelling the needs-alt notice's frontmatter rows. Only
970
+ // top-level image fields are enumerated. A nested image (an array(image) gallery item or an object
971
+ // image sub-field) is intentionally out of scope for the needs-alt notice this phase, the recorded
972
+ // carry-forward, so the flat top-level scan is deliberate, not an oversight.
974
973
  const imageFields = $derived(
975
974
  data.fields.filter((f) => f.type === 'image').map((f) => ({ name: f.name, label: f.label })),
976
975
  );
@@ -1061,6 +1060,14 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1061
1060
  return drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
1062
1061
  });
1063
1062
 
1063
+ // A save whose frontmatter references an absent or draft target carries ?refs=<concept/id list>,
1064
+ // the advisory reference warning the save threads through (mirroring ?drafts=). It never blocks the
1065
+ // save; the build's verifyReferences is the integrity authority, so this is informational only.
1066
+ const referenceWarning = $derived.by(() => {
1067
+ const refs = page.url.searchParams.get('refs');
1068
+ return refs ? refs.split(',').filter(Boolean).join(', ') : '';
1069
+ });
1070
+
1064
1071
  // The one transient feedback strip under the sticky header. The redirect flags are mutually
1065
1072
  // exclusive in practice; the chain picks one so a surprise overlap still renders a single strip.
1066
1073
  // A saved flash with a draft warning yields to the warning alert below, the prior behavior.
@@ -1078,9 +1085,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1078
1085
  // notices (the flash, plus the draft notice the strip yields to); an assertive region carries
1079
1086
  // the errors. The visible banners below keep their styling but drop their roles, so a message
1080
1087
  // is announced once.
1081
- const politeMessage = $derived(
1082
- draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash,
1083
- );
1088
+ const politeMessage = $derived.by(() => {
1089
+ if (draftWarning) return `Saved. This page links to unpublished pages: ${draftWarning}.`;
1090
+ if (referenceWarning) return `Saved. This page references unpublished entries: ${referenceWarning}.`;
1091
+ return flash;
1092
+ });
1084
1093
  const assertiveMessage = $derived.by(() => {
1085
1094
  if (data.error) return data.error;
1086
1095
  if (formError) return formError;
@@ -1208,6 +1217,9 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1208
1217
 
1209
1218
  // Render the design-accurate preview as the body changes, debounced. The site's render is the
1210
1219
  // floored engine pipeline, so its output is already sanitized; the preview mirrors the page.
1220
+ // The preview call threads the same entry context the public route passes (concept and
1221
+ // frontmatter), so a custom entry-aware renderer's preview matches its page. The body is the live
1222
+ // editor content; frontmatter is the loaded snapshot, which is faithful for the saved state.
1211
1223
  // previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
1212
1224
  // async render call resolves after a newer one has started, the stale result is discarded.
1213
1225
  let previewRun = 0;
@@ -1219,7 +1231,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1219
1231
  const run = ++previewRun;
1220
1232
  const handle = setTimeout(async () => {
1221
1233
  try {
1222
- const html = await render(md, { resolve, resolveMedia: resolveMediaRef });
1234
+ const html = await render({ body: md, concept: data.conceptId, frontmatter: data.frontmatter, resolve, resolveMedia: resolveMediaRef });
1223
1235
  if (run === previewRun) {
1224
1236
  previewHtml = html;
1225
1237
  previewFailed = false;
@@ -1254,11 +1266,6 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1254
1266
  const titleField = $derived(data.fields.find((f) => f.name === 'title'));
1255
1267
  const draftField = $derived(data.fields.find((f) => f.type === 'boolean' && f.name === 'draft'));
1256
1268
  const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
1257
-
1258
- // The built-in hint a date field carries when its adapter sets no description. The control reads as
1259
- // if it might schedule publishing, so this reassures the editor that the date is metadata and that
1260
- // publishing is the separate, deliberate step. A field-level description overrides it.
1261
- const DATE_PUBLISH_HINT = 'Sets the date for this post. Publishing is a separate step you choose.';
1262
1269
  </script>
1263
1270
 
1264
1271
  <!-- The desk controls live in the one header band: AdminLayout renders this snippet through the
@@ -1385,17 +1392,6 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1385
1392
  </div>
1386
1393
  {/snippet}
1387
1394
 
1388
- <!-- The author-facing hint under a Details field. The id pairs with the input's aria-describedby
1389
- (`<name>-hint`); its uniqueness rests on schema field names being unique within a concept, which
1390
- is also the loop key. So assistive tech announces the sentence without bloating the accessible
1391
- name. Each field branch decides whether and where to render it; this snippet holds the one shape.
1392
- The `fld-hint` class is a styling hook with no rule today; the Tailwind utilities do the work. -->
1393
- {#snippet fieldHint(name: string, text: string)}
1394
- <p id={`${name}-hint`} class="fld-hint mt-1 text-sm text-[var(--color-muted)]">
1395
- {text}
1396
- </p>
1397
- {/snippet}
1398
-
1399
1395
  <!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
1400
1396
  reset above); script-level state and the beforeNavigate registration sit outside the block,
1401
1397
  so only the template rebuilds. -->
@@ -1503,6 +1499,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1503
1499
  Saved. Note: this page links to unpublished {draftWarning.includes(',') ? 'pages' : 'a page'} ({draftWarning}), which will 404 until published.
1504
1500
  </div>
1505
1501
  {/if}
1502
+ {#if referenceWarning}
1503
+ <div class="alert alert-warning mb-4 text-sm">
1504
+ Saved. Note: this page references {referenceWarning.includes(',') ? 'entries' : 'an entry'} ({referenceWarning}) not yet published, which the build will flag until published.
1505
+ </div>
1506
+ {/if}
1506
1507
 
1507
1508
  <form
1508
1509
  method="POST"
@@ -1884,96 +1885,19 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1884
1885
  the screen-reader grouping but hides visually, the way the mockup carries it once. -->
1885
1886
  <legend class="sr-only">Details</legend>
1886
1887
  {#each detailFields as field (field.name)}
1887
- {#if field.type === 'textarea'}
1888
- {@const f = field as TextareaField}
1889
- <label class="flex flex-col gap-1">
1890
- <span class="text-sm font-medium">{f.label}</span>
1891
- <textarea class="textarea textarea-sm" name={f.name} aria-label={f.label} aria-describedby={f.description ? `${f.name}-hint` : undefined} rows={f.rows ?? 3}>{str(data.frontmatter[f.name])}</textarea>
1892
- {#if f.description}
1893
- {@render fieldHint(f.name, f.description)}
1894
- {/if}
1895
- </label>
1896
- {:else if field.type === 'date'}
1897
- <label class="flex flex-col gap-1">
1898
- <span class="text-sm font-medium">{field.label}</span>
1899
- <!-- A date field always carries a hint: the adapter's description when set, else the
1900
- built-in publish-clarity default. So aria-describedby always points at the paragraph. -->
1901
- <input class="input input-sm" type="date" name={field.name} aria-label={field.label} aria-describedby={`${field.name}-hint`} value={str(data.frontmatter[field.name])} />
1902
- {@render fieldHint(field.name, field.description || DATE_PUBLISH_HINT)}
1903
- </label>
1904
- {:else if field.type === 'boolean'}
1905
- <div class="flex flex-col gap-1">
1906
- <label class="label cursor-pointer justify-start gap-2">
1907
- <input class="checkbox checkbox-sm" type="checkbox" name={field.name} aria-label={field.label} aria-describedby={field.description ? `${field.name}-hint` : undefined} checked={data.frontmatter[field.name] === true} />
1908
- <span class="text-sm">{field.label}</span>
1909
- </label>
1910
- {#if field.description}
1911
- {@render fieldHint(field.name, field.description)}
1912
- {/if}
1913
- </div>
1914
- {:else if field.type === 'tags'}
1915
- {@const f = field as TagsField}
1916
- {@const selected = (data.frontmatter[f.name] ?? []) as string[]}
1917
- <fieldset class="fieldset" aria-describedby={f.description ? `${f.name}-hint` : undefined}>
1918
- <legend class="fieldset-legend">{f.label}</legend>
1919
- {#if f.description}
1920
- {@render fieldHint(f.name, f.description)}
1921
- {/if}
1922
- <div class="flex flex-wrap gap-2">
1923
- {#each f.options as option (option)}
1924
- <label class="label cursor-pointer justify-start gap-2">
1925
- <input
1926
- class="checkbox checkbox-sm"
1927
- type="checkbox"
1928
- name={f.name}
1929
- value={option}
1930
- checked={selected.includes(option)}
1931
- />
1932
- <span class="text-sm">{option}</span>
1933
- </label>
1934
- {/each}
1935
- </div>
1936
- </fieldset>
1937
- {:else if field.type === 'freetags'}
1938
- {@const f = field as FreeTagsField}
1939
- {@const tagValue = ((data.frontmatter[f.name] ?? []) as string[]).join(', ')}
1940
- <label class="flex flex-col gap-1">
1941
- <span class="text-sm font-medium">{f.label}</span>
1942
- <input
1943
- class="input input-sm"
1944
- name={f.name}
1945
- aria-label={f.label}
1946
- aria-describedby={f.description ? `${f.name}-hint` : undefined}
1947
- placeholder={f.placeholder}
1948
- value={tagValue}
1949
- />
1950
- {#if f.description}
1951
- {@render fieldHint(f.name, f.description)}
1952
- {/if}
1953
- </label>
1954
- {:else if field.type === 'image'}
1955
- {@const heroValue = data.frontmatter[field.name] as ImageValue | undefined}
1956
- <MediaHeroField
1957
- bind:this={heroFieldRefs[field.name]}
1958
- field={{ name: field.name, label: field.label }}
1959
- value={heroValue}
1960
- decorative={heroValue?.decorative ?? false}
1961
- mediaLibrary={mediaLibrary}
1962
- conceptId={data.conceptId}
1963
- id={data.id}
1964
- onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
1965
- ondirty={markFieldsDirty}
1966
- onneedsaltchange={(n) => (heroNeedsAlt = { ...heroNeedsAlt, [field.name]: n })}
1967
- />
1968
- {:else}
1969
- <label class="flex flex-col gap-1">
1970
- <span class="text-sm font-medium">{field.label}</span>
1971
- <input class="input input-sm" name={field.name} aria-label={field.label} aria-describedby={field.description ? `${field.name}-hint` : undefined} value={str(data.frontmatter[field.name])} required={field.required} />
1972
- {#if field.description}
1973
- {@render fieldHint(field.name, field.description)}
1974
- {/if}
1975
- </label>
1976
- {/if}
1888
+ <FieldInput
1889
+ {field}
1890
+ frontmatter={data.frontmatter}
1891
+ targets={data.linkTargets}
1892
+ markFieldsDirty={markFieldsDirty}
1893
+ mediaLibrary={mediaLibrary}
1894
+ conceptId={data.conceptId}
1895
+ id={data.id}
1896
+ heroFieldRefs={heroFieldRefs}
1897
+ onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
1898
+ onheroneedsalt={(name, n) => (heroNeedsAlt = { ...heroNeedsAlt, [name]: n })}
1899
+ {icons}
1900
+ />
1977
1901
  {/each}
1978
1902
  </fieldset>
1979
1903
  {/if}
@@ -0,0 +1,154 @@
1
+ <!--
2
+ @component
3
+ The search + concept-grouped target list shared by the editor's "Link to page" control and the
4
+ reference field picker. It lists link targets from the committed manifest, grouped by concept with
5
+ Pages first then Posts then any other concept, each post showing its date and each draft marked, and
6
+ fires choose() with the picked target. It knows nothing about cairn: tokens or the editor cursor; the
7
+ host decides what a chosen target means. An optional conceptFilter narrows the list to one concept,
8
+ and selectedIds marks rows the host already holds. Built on a native <dialog>, following the component
9
+ dialog's a11y conventions.
10
+ -->
11
+ <script lang="ts">
12
+ import type { LinkTarget } from '../content/manifest.js';
13
+
14
+ interface Props {
15
+ /** The site's link targets, from the committed manifest (editLoad ships them). */
16
+ targets: LinkTarget[];
17
+ /** Called with the target the user picked; the host decides what to do with it. */
18
+ choose: (target: LinkTarget) => void;
19
+ /** Narrow the list to a single concept (the reference field's concept). */
20
+ conceptFilter?: string;
21
+ /** Ids the host already holds; matching rows render as already-selected and do not re-fire choose. */
22
+ selectedIds?: string[];
23
+ /** Render the built-in trigger button. False mounts only the dialog, for a host that supplies its
24
+ * own trigger and opens the dialog through the exported open(). */
25
+ trigger?: boolean;
26
+ /** The dialog title. Defaults to the editor link control's wording; a reference field passes a
27
+ * concept-appropriate heading. */
28
+ heading?: string;
29
+ /** The search input's accessible name. Defaults to the link control's wording. */
30
+ searchLabel?: string;
31
+ /** The empty-state text shown when no target matches. Defaults to the link control's wording. */
32
+ emptyText?: string;
33
+ }
34
+
35
+ let {
36
+ targets,
37
+ choose,
38
+ conceptFilter,
39
+ selectedIds = [],
40
+ trigger = true,
41
+ heading: dialogHeading = 'Link to a page',
42
+ searchLabel = 'Search pages and posts',
43
+ emptyText = 'No pages or posts to link to.',
44
+ }: Props = $props();
45
+
46
+ let dialog = $state<HTMLDialogElement | null>(null);
47
+ let searchInput = $state<HTMLInputElement | null>(null);
48
+ let query = $state('');
49
+
50
+ // Group filtered targets by concept, Pages first then Posts then any other concept, so the list
51
+ // reads in a stable order. The filter is a case-insensitive title substring.
52
+ const ORDER: Record<string, number> = { pages: 0, posts: 1 };
53
+ function rank(concept: string): number {
54
+ return ORDER[concept] ?? 2;
55
+ }
56
+ function heading(concept: string): string {
57
+ if (concept === 'pages') return 'Pages';
58
+ if (concept === 'posts') return 'Posts';
59
+ return concept.charAt(0).toUpperCase() + concept.slice(1);
60
+ }
61
+
62
+ const groups = $derived.by(() => {
63
+ const q = query.trim().toLowerCase();
64
+ const scoped = conceptFilter ? targets.filter((t) => t.concept === conceptFilter) : targets;
65
+ const matched = q ? scoped.filter((t) => t.title.toLowerCase().includes(q)) : scoped;
66
+ const byConcept = new Map<string, LinkTarget[]>();
67
+ for (const t of matched) {
68
+ const list = byConcept.get(t.concept) ?? [];
69
+ list.push(t);
70
+ byConcept.set(t.concept, list);
71
+ }
72
+ return [...byConcept.entries()]
73
+ .map(([concept, items]) => ({ concept, heading: heading(concept), items }))
74
+ .sort((a, b) => rank(a.concept) - rank(b.concept) || a.heading.localeCompare(b.heading));
75
+ });
76
+
77
+ function isSelected(target: LinkTarget): boolean {
78
+ return selectedIds.includes(target.id);
79
+ }
80
+
81
+ /** Open the picker programmatically, for a host that drives it without the trigger. */
82
+ export function open() {
83
+ query = '';
84
+ dialog?.showModal();
85
+ // showModal() lands focus on the first focusable element (the header Close button), so move it
86
+ // to the search box the picker exists for (WCAG 2.4.3). A microtask defers past the dialog's own
87
+ // focus handling, matching RenameDialog.
88
+ queueMicrotask(() => searchInput?.focus());
89
+ }
90
+ function close() {
91
+ dialog?.close();
92
+ }
93
+ function pick(target: LinkTarget) {
94
+ if (isSelected(target)) return;
95
+ choose(target);
96
+ close();
97
+ }
98
+ </script>
99
+
100
+ {#if trigger}
101
+ <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Link to page" onclick={open}>
102
+ Link to page
103
+ </button>
104
+ {/if}
105
+
106
+ <dialog class="modal" aria-labelledby="cairn-entry-picker-title" bind:this={dialog}>
107
+ <div class="modal-box">
108
+ <div class="mb-3 flex items-center justify-between">
109
+ <h2 id="cairn-entry-picker-title" class="text-base font-semibold">{dialogHeading}</h2>
110
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
111
+ </div>
112
+
113
+ <input
114
+ type="search"
115
+ class="input input-bordered mb-3 w-full"
116
+ placeholder="Search by title"
117
+ aria-label={searchLabel}
118
+ bind:this={searchInput}
119
+ bind:value={query}
120
+ />
121
+
122
+ {#if groups.length === 0}
123
+ <p class="text-sm text-[var(--color-muted)]">{emptyText}</p>
124
+ {:else}
125
+ {#each groups as group (group.concept)}
126
+ <h3 class="mt-2 mb-1 text-xs font-semibold tracking-wide text-[var(--color-muted)] uppercase">{group.heading}</h3>
127
+ <ul class="menu w-full">
128
+ {#each group.items as target (`${target.concept}/${target.id}`)}
129
+ <li>
130
+ <button
131
+ type="button"
132
+ aria-disabled={isSelected(target)}
133
+ aria-label={isSelected(target) ? `${target.title} (already selected)` : target.title}
134
+ onclick={() => pick(target)}
135
+ >
136
+ <span class="flex flex-col items-start">
137
+ <span class="font-medium">{target.title}</span>
138
+ <span class="text-xs text-[var(--color-muted)]">
139
+ {#if isSelected(target)}<span class="badge badge-ghost badge-sm mr-1">Selected</span>{/if}
140
+ {#if target.draft}<span class="badge badge-ghost badge-sm mr-1">Draft</span>{/if}
141
+ {#if target.date}{target.date}{/if}
142
+ </span>
143
+ </span>
144
+ </button>
145
+ </li>
146
+ {/each}
147
+ </ul>
148
+ {/each}
149
+ {/if}
150
+ </div>
151
+ <form method="dialog" class="modal-backdrop">
152
+ <button tabindex="-1" aria-label="Close">close</button>
153
+ </form>
154
+ </dialog>
@@ -0,0 +1,262 @@
1
+ <!--
2
+ @component
3
+ The leaf-field dispatcher for the edit page's Details panel. It renders one leaf field (a scalar,
4
+ an image, or a reference) as the matching input, picked by `field.type`.
5
+
6
+ It is name-prefixable so a container row can reuse it one level down. The `name` prop is the form
7
+ input name: it defaults to `field.name` at the top level, and a container caller passes a prefixed
8
+ path (`${parent}.${index}` for an array element, `${parent}.${leafKey}` for an object leaf) so the
9
+ nested decode in `frontmatter.ts` reads the value back from the right slot. Each arm reads its value
10
+ from `frontmatter[field.name]`, so a nested caller passes the row or object slice as `frontmatter`
11
+ and the leaf key as `field.name`, leaving the reads unchanged.
12
+
13
+ An `object` field renders a labeled `ObjectGroupField`, and a non-reference `array` renders a
14
+ `RepeatableField`; both recurse one level back through this dispatcher for their leaves, which the
15
+ one-level nesting cap (the declaration guard) bounds so the recursion terminates. An
16
+ `array(reference)` stays on `ReferenceField`.
17
+ -->
18
+ <script lang="ts">
19
+ import MediaHeroField from './MediaHeroField.svelte';
20
+ import ReferenceField from './ReferenceField.svelte';
21
+ import ObjectGroupField from './ObjectGroupField.svelte';
22
+ import RepeatableField from './RepeatableField.svelte';
23
+ import IconPicker from './IconPicker.svelte';
24
+ import { isClosedMultiselect } from '../content/frontmatter.js';
25
+ import type { ImageValue, NamedField } from '../content/types.js';
26
+ import type { TextareaField, NumberField, SelectField, MultiselectField } from '../content/fields.js';
27
+ import type { IconSet } from '../render/glyph.js';
28
+ import type { LinkTarget } from '../content/manifest.js';
29
+ import type { MediaEntry } from '../media/manifest.js';
30
+ import type { MediaLibraryEntry } from '../media/library-entry.js';
31
+
32
+ interface Props {
33
+ /** The leaf field to render; its `name` is the frontmatter key the arm reads its value from. */
34
+ field: NamedField;
35
+ /** The form input name. Defaults to `field.name`; a container caller passes a prefixed path. */
36
+ name?: string;
37
+ /** The frontmatter slice this field reads from, keyed by `field.name`. */
38
+ frontmatter: Record<string, unknown>;
39
+ /** The site link targets the reference arm offers. */
40
+ targets: LinkTarget[];
41
+ /** Mark the edit form dirty; the image arm wires it to the hero field's commit. */
42
+ markFieldsDirty: () => void;
43
+ /** The merged committed-plus-uploaded media library, keyed by content hash. */
44
+ mediaLibrary: Record<string, MediaLibraryEntry>;
45
+ /** The concept the entry belongs to (the upload action's route param). */
46
+ conceptId: string;
47
+ /** The entry id (the upload action's route param). */
48
+ id: string;
49
+ /** The host's hero-field refs, keyed by the prefixed `name` so two rows do not collide. */
50
+ heroFieldRefs: Record<string, MediaHeroField>;
51
+ /** Called with the server-owned record on a successful upload, so the host merges it. */
52
+ onuploaded: (record: MediaEntry) => void;
53
+ /** Called when a hero's needs-alt status changes, keyed by the prefixed `name`. */
54
+ onheroneedsalt: (name: string, needsAlt: boolean) => void;
55
+ /** The site's icon set, threaded to the icon arm's picker. Absent when the site ships none. */
56
+ icons?: IconSet;
57
+ }
58
+
59
+ let {
60
+ field,
61
+ name = field.name,
62
+ frontmatter,
63
+ targets,
64
+ markFieldsDirty,
65
+ mediaLibrary,
66
+ conceptId,
67
+ id,
68
+ heroFieldRefs,
69
+ onuploaded,
70
+ onheroneedsalt,
71
+ icons,
72
+ }: Props = $props();
73
+
74
+ function str(v: unknown): string {
75
+ return v == null ? '' : String(v);
76
+ }
77
+
78
+ // The HTML input type for a plain single-line text input arm (url, email, datetime, and the text
79
+ // fallback). datetime maps to the datetime-local control; everything else carries no type attribute
80
+ // so the browser defaults to a text input.
81
+ function inputType(fieldType: string): 'url' | 'email' | 'datetime-local' | undefined {
82
+ switch (fieldType) {
83
+ case 'url':
84
+ return 'url';
85
+ case 'email':
86
+ return 'email';
87
+ case 'datetime':
88
+ return 'datetime-local';
89
+ default:
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ // The built-in hint a date field carries when its adapter sets no description. The control reads as
95
+ // if it might schedule publishing, so this reassures the editor that the date is metadata and that
96
+ // publishing is the separate, deliberate step. A field-level description overrides it.
97
+ const DATE_PUBLISH_HINT = 'Sets the date for this post. Publishing is a separate step you choose.';
98
+ </script>
99
+
100
+ {#snippet fieldHint(hintName: string, text: string)}
101
+ <p id={`${hintName}-hint`} class="fld-hint mt-1 text-sm text-[var(--color-muted)]">
102
+ {text}
103
+ </p>
104
+ {/snippet}
105
+
106
+ {#if field.type === 'textarea'}
107
+ {@const f = field as NamedField & TextareaField}
108
+ <label class="flex flex-col gap-1">
109
+ <span class="text-sm font-medium">{f.label}</span>
110
+ <textarea class="textarea textarea-sm" {name} aria-label={f.label} aria-describedby={f.help ? `${f.name}-hint` : undefined} rows={f.rows ?? 3}>{str(frontmatter[f.name])}</textarea>
111
+ {#if f.help}
112
+ {@render fieldHint(f.name, f.help)}
113
+ {/if}
114
+ </label>
115
+ {:else if field.type === 'number'}
116
+ {@const f = field as NamedField & NumberField}
117
+ <label class="flex flex-col gap-1">
118
+ <span class="text-sm font-medium">{f.label}</span>
119
+ <input
120
+ class="input input-sm"
121
+ type="number"
122
+ {name}
123
+ aria-label={f.label}
124
+ aria-describedby={f.help ? `${f.name}-hint` : undefined}
125
+ min={f.min}
126
+ max={f.max}
127
+ step={f.integer ? 1 : undefined}
128
+ value={str(frontmatter[f.name])}
129
+ required={f.required}
130
+ />
131
+ {#if f.help}
132
+ {@render fieldHint(f.name, f.help)}
133
+ {/if}
134
+ </label>
135
+ {:else if field.type === 'select'}
136
+ {@const f = field as NamedField & SelectField}
137
+ <label class="flex flex-col gap-1">
138
+ <span class="text-sm font-medium">{f.label}</span>
139
+ <select class="select select-sm" {name} aria-label={f.label} aria-describedby={f.help ? `${f.name}-hint` : undefined} required={f.required}>
140
+ <!-- A leading empty option submits '' (the key is dropped on save); a required select
141
+ leaves it unselected so an unset value fails the required check with a clear message. -->
142
+ <option value="">&mdash; none &mdash;</option>
143
+ {#each f.options as option (option)}
144
+ <option value={option} selected={str(frontmatter[f.name]) === option}>{option}</option>
145
+ {/each}
146
+ </select>
147
+ {#if f.help}
148
+ {@render fieldHint(f.name, f.help)}
149
+ {/if}
150
+ </label>
151
+ {:else if field.type === 'date'}
152
+ <label class="flex flex-col gap-1">
153
+ <span class="text-sm font-medium">{field.label}</span>
154
+ <!-- A date field always carries a hint: the adapter's help when set, else the
155
+ built-in publish-clarity default. So aria-describedby always points at the paragraph. -->
156
+ <input class="input input-sm" type="date" {name} aria-label={field.label} aria-describedby={`${field.name}-hint`} value={str(frontmatter[field.name])} />
157
+ {@render fieldHint(field.name, field.help || DATE_PUBLISH_HINT)}
158
+ </label>
159
+ {:else if field.type === 'boolean'}
160
+ <div class="flex flex-col gap-1">
161
+ <label class="label cursor-pointer justify-start gap-2">
162
+ <input class="checkbox checkbox-sm" type="checkbox" {name} aria-label={field.label} aria-describedby={field.help ? `${field.name}-hint` : undefined} checked={frontmatter[field.name] === true} />
163
+ <span class="text-sm">{field.label}</span>
164
+ </label>
165
+ {#if field.help}
166
+ {@render fieldHint(field.name, field.help)}
167
+ {/if}
168
+ </div>
169
+ {:else if field.type === 'multiselect' && isClosedMultiselect(field)}
170
+ {@const f = field as NamedField & MultiselectField & { options: readonly string[] }}
171
+ {@const selected = (frontmatter[f.name] ?? []) as string[]}
172
+ <fieldset class="fieldset" aria-describedby={f.help ? `${f.name}-hint` : undefined}>
173
+ <legend class="fieldset-legend">{f.label}</legend>
174
+ {#if f.help}
175
+ {@render fieldHint(f.name, f.help)}
176
+ {/if}
177
+ <div class="flex flex-wrap gap-2">
178
+ {#each f.options as option (option)}
179
+ <label class="label cursor-pointer justify-start gap-2">
180
+ <input
181
+ class="checkbox checkbox-sm"
182
+ type="checkbox"
183
+ {name}
184
+ value={option}
185
+ checked={selected.includes(option)}
186
+ />
187
+ <span class="text-sm">{option}</span>
188
+ </label>
189
+ {/each}
190
+ </div>
191
+ </fieldset>
192
+ {:else if field.type === 'multiselect'}
193
+ {@const f = field as NamedField & MultiselectField}
194
+ {@const tagValue = ((frontmatter[f.name] ?? []) as string[]).join(', ')}
195
+ <label class="flex flex-col gap-1">
196
+ <span class="text-sm font-medium">{f.label}</span>
197
+ <input
198
+ class="input input-sm"
199
+ {name}
200
+ aria-label={f.label}
201
+ aria-describedby={f.help ? `${f.name}-hint` : undefined}
202
+ placeholder={f.placeholder ?? (f.help ? undefined : 'Separate values with commas')}
203
+ value={tagValue}
204
+ />
205
+ {#if f.help}
206
+ {@render fieldHint(f.name, f.help)}
207
+ {/if}
208
+ </label>
209
+ {:else if field.type === 'image'}
210
+ {@const heroValue = frontmatter[field.name] as ImageValue | undefined}
211
+ <!-- The binding_property_non_reactive warning this logs is benign: the parent owns the $state
212
+ proxy and mutates it by reference, and the hero-alt focus flow reads the same prefixed key. -->
213
+ <MediaHeroField
214
+ bind:this={heroFieldRefs[name]}
215
+ field={{ name, label: field.label }}
216
+ value={heroValue}
217
+ decorative={heroValue?.decorative ?? false}
218
+ lead={field.type === 'image' && field.seo === true}
219
+ mediaLibrary={mediaLibrary}
220
+ conceptId={conceptId}
221
+ id={id}
222
+ onuploaded={onuploaded}
223
+ ondirty={markFieldsDirty}
224
+ onneedsaltchange={(n) => onheroneedsalt(name, n)}
225
+ />
226
+ {:else if field.type === 'reference'}
227
+ <ReferenceField {field} value={(frontmatter[field.name] ?? '') as string} {targets} ondirty={markFieldsDirty} />
228
+ {:else if field.type === 'array' && field.item.type === 'reference'}
229
+ <ReferenceField {field} value={(frontmatter[field.name] ?? []) as string[]} {targets} ondirty={markFieldsDirty} />
230
+ {:else if field.type === 'object'}
231
+ <ObjectGroupField {field} {name} frontmatter={(frontmatter[field.name] ?? {}) as Record<string, unknown>} {markFieldsDirty} {mediaLibrary} {conceptId} {id} {heroFieldRefs} {targets} {onuploaded} {onheroneedsalt} {icons} />
232
+ {:else if field.type === 'array' && field.item.type !== 'reference'}
233
+ <RepeatableField {field} {name} rows={(frontmatter[field.name] ?? []) as unknown[]} {markFieldsDirty} {mediaLibrary} {conceptId} {id} {heroFieldRefs} {targets} {onuploaded} {onheroneedsalt} {icons} />
234
+ {:else if field.type === 'icon' && icons}
235
+ <div class="flex flex-col gap-1">
236
+ <span class="text-sm font-medium">{field.label}</span>
237
+ <IconPicker
238
+ {icons}
239
+ label={field.label}
240
+ describedby={field.help ? `${field.name}-hint` : undefined}
241
+ value={typeof frontmatter[field.name] === 'string' ? (frontmatter[field.name] as string) : ''}
242
+ required={field.required ?? false}
243
+ onChange={(glyph) => {
244
+ frontmatter[field.name] = glyph;
245
+ markFieldsDirty();
246
+ }}
247
+ />
248
+ {#if field.help}
249
+ {@render fieldHint(field.name, field.help)}
250
+ {/if}
251
+ </div>
252
+ {:else}
253
+ <!-- The plain single-line text input arm: url, email, datetime, and the text fallback. They share
254
+ one shape and differ only in the input type inputType() resolves. -->
255
+ <label class="flex flex-col gap-1">
256
+ <span class="text-sm font-medium">{field.label}</span>
257
+ <input class="input input-sm" type={inputType(field.type)} {name} aria-label={field.label} aria-describedby={field.help ? `${field.name}-hint` : undefined} value={str(frontmatter[field.name])} required={field.required} />
258
+ {#if field.help}
259
+ {@render fieldHint(field.name, field.help)}
260
+ {/if}
261
+ </label>
262
+ {/if}