@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
@@ -34,7 +34,7 @@ import ComponentInsertDialog, { insertableDefs, hasSchema } from "./ComponentIns
34
34
  import LinkPicker from "./LinkPicker.svelte";
35
35
  import WebLinkDialog from "./WebLinkDialog.svelte";
36
36
  import MediaInsertPopover from "./MediaInsertPopover.svelte";
37
- import MediaHeroField from "./MediaHeroField.svelte";
37
+ import FieldInput from "./FieldInput.svelte";
38
38
  import MediaFigureControl from "./MediaFigureControl.svelte";
39
39
  import DeleteDialog from "./DeleteDialog.svelte";
40
40
  import RenameDialog from "./RenameDialog.svelte";
@@ -614,6 +614,10 @@ const draftWarning = $derived.by(() => {
614
614
  const drafts = page.url.searchParams.get("drafts");
615
615
  return drafts ? drafts.split(",").filter(Boolean).join(", ") : "";
616
616
  });
617
+ const referenceWarning = $derived.by(() => {
618
+ const refs = page.url.searchParams.get("refs");
619
+ return refs ? refs.split(",").filter(Boolean).join(", ") : "";
620
+ });
617
621
  const flash = $derived.by(() => {
618
622
  if (data.saved && !draftWarning)
619
623
  return "Saved. Your site keeps showing the published version until you publish.";
@@ -622,9 +626,11 @@ const flash = $derived.by(() => {
622
626
  if (data.renamed) return `The URL is now ${data.slug}.`;
623
627
  return "";
624
628
  });
625
- const politeMessage = $derived(
626
- draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash
627
- );
629
+ const politeMessage = $derived.by(() => {
630
+ if (draftWarning) return `Saved. This page links to unpublished pages: ${draftWarning}.`;
631
+ if (referenceWarning) return `Saved. This page references unpublished entries: ${referenceWarning}.`;
632
+ return flash;
633
+ });
628
634
  const assertiveMessage = $derived.by(() => {
629
635
  if (data.error) return data.error;
630
636
  if (formError) return formError;
@@ -718,7 +724,7 @@ $effect(() => {
718
724
  const run = ++previewRun;
719
725
  const handle = setTimeout(async () => {
720
726
  try {
721
- const html = await render(md, { resolve, resolveMedia: resolveMediaRef });
727
+ const html = await render({ body: md, concept: data.conceptId, frontmatter: data.frontmatter, resolve, resolveMedia: resolveMediaRef });
722
728
  if (run === previewRun) {
723
729
  previewHtml = html;
724
730
  previewFailed = false;
@@ -742,7 +748,6 @@ const eyebrowClass = "mb-2 text-[0.6875rem] font-semibold uppercase tracking-[0.
742
748
  const titleField = $derived(data.fields.find((f) => f.name === "title"));
743
749
  const draftField = $derived(data.fields.find((f) => f.type === "boolean" && f.name === "draft"));
744
750
  const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
745
- const DATE_PUBLISH_HINT = "Sets the date for this post. Publishing is a separate step you choose.";
746
751
  </script>
747
752
 
748
753
  <!-- The desk controls live in the one header band: AdminLayout renders this snippet through the
@@ -869,17 +874,6 @@ const DATE_PUBLISH_HINT = "Sets the date for this post. Publishing is a separate
869
874
  </div>
870
875
  {/snippet}
871
876
 
872
- <!-- The author-facing hint under a Details field. The id pairs with the input's aria-describedby
873
- (`<name>-hint`); its uniqueness rests on schema field names being unique within a concept, which
874
- is also the loop key. So assistive tech announces the sentence without bloating the accessible
875
- name. Each field branch decides whether and where to render it; this snippet holds the one shape.
876
- The `fld-hint` class is a styling hook with no rule today; the Tailwind utilities do the work. -->
877
- {#snippet fieldHint(name: string, text: string)}
878
- <p id={`${name}-hint`} class="fld-hint mt-1 text-sm text-[var(--color-muted)]">
879
- {text}
880
- </p>
881
- {/snippet}
882
-
883
877
  <!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
884
878
  reset above); script-level state and the beforeNavigate registration sit outside the block,
885
879
  so only the template rebuilds. -->
@@ -987,6 +981,11 @@ const DATE_PUBLISH_HINT = "Sets the date for this post. Publishing is a separate
987
981
  Saved. Note: this page links to unpublished {draftWarning.includes(',') ? 'pages' : 'a page'} ({draftWarning}), which will 404 until published.
988
982
  </div>
989
983
  {/if}
984
+ {#if referenceWarning}
985
+ <div class="alert alert-warning mb-4 text-sm">
986
+ Saved. Note: this page references {referenceWarning.includes(',') ? 'entries' : 'an entry'} ({referenceWarning}) not yet published, which the build will flag until published.
987
+ </div>
988
+ {/if}
990
989
 
991
990
  <form
992
991
  method="POST"
@@ -1368,96 +1367,19 @@ const DATE_PUBLISH_HINT = "Sets the date for this post. Publishing is a separate
1368
1367
  the screen-reader grouping but hides visually, the way the mockup carries it once. -->
1369
1368
  <legend class="sr-only">Details</legend>
1370
1369
  {#each detailFields as field (field.name)}
1371
- {#if field.type === 'textarea'}
1372
- {@const f = field as TextareaField}
1373
- <label class="flex flex-col gap-1">
1374
- <span class="text-sm font-medium">{f.label}</span>
1375
- <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>
1376
- {#if f.description}
1377
- {@render fieldHint(f.name, f.description)}
1378
- {/if}
1379
- </label>
1380
- {:else if field.type === 'date'}
1381
- <label class="flex flex-col gap-1">
1382
- <span class="text-sm font-medium">{field.label}</span>
1383
- <!-- A date field always carries a hint: the adapter's description when set, else the
1384
- built-in publish-clarity default. So aria-describedby always points at the paragraph. -->
1385
- <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])} />
1386
- {@render fieldHint(field.name, field.description || DATE_PUBLISH_HINT)}
1387
- </label>
1388
- {:else if field.type === 'boolean'}
1389
- <div class="flex flex-col gap-1">
1390
- <label class="label cursor-pointer justify-start gap-2">
1391
- <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} />
1392
- <span class="text-sm">{field.label}</span>
1393
- </label>
1394
- {#if field.description}
1395
- {@render fieldHint(field.name, field.description)}
1396
- {/if}
1397
- </div>
1398
- {:else if field.type === 'tags'}
1399
- {@const f = field as TagsField}
1400
- {@const selected = (data.frontmatter[f.name] ?? []) as string[]}
1401
- <fieldset class="fieldset" aria-describedby={f.description ? `${f.name}-hint` : undefined}>
1402
- <legend class="fieldset-legend">{f.label}</legend>
1403
- {#if f.description}
1404
- {@render fieldHint(f.name, f.description)}
1405
- {/if}
1406
- <div class="flex flex-wrap gap-2">
1407
- {#each f.options as option (option)}
1408
- <label class="label cursor-pointer justify-start gap-2">
1409
- <input
1410
- class="checkbox checkbox-sm"
1411
- type="checkbox"
1412
- name={f.name}
1413
- value={option}
1414
- checked={selected.includes(option)}
1415
- />
1416
- <span class="text-sm">{option}</span>
1417
- </label>
1418
- {/each}
1419
- </div>
1420
- </fieldset>
1421
- {:else if field.type === 'freetags'}
1422
- {@const f = field as FreeTagsField}
1423
- {@const tagValue = ((data.frontmatter[f.name] ?? []) as string[]).join(', ')}
1424
- <label class="flex flex-col gap-1">
1425
- <span class="text-sm font-medium">{f.label}</span>
1426
- <input
1427
- class="input input-sm"
1428
- name={f.name}
1429
- aria-label={f.label}
1430
- aria-describedby={f.description ? `${f.name}-hint` : undefined}
1431
- placeholder={f.placeholder}
1432
- value={tagValue}
1433
- />
1434
- {#if f.description}
1435
- {@render fieldHint(f.name, f.description)}
1436
- {/if}
1437
- </label>
1438
- {:else if field.type === 'image'}
1439
- {@const heroValue = data.frontmatter[field.name] as ImageValue | undefined}
1440
- <MediaHeroField
1441
- bind:this={heroFieldRefs[field.name]}
1442
- field={{ name: field.name, label: field.label }}
1443
- value={heroValue}
1444
- decorative={heroValue?.decorative ?? false}
1445
- mediaLibrary={mediaLibrary}
1446
- conceptId={data.conceptId}
1447
- id={data.id}
1448
- onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
1449
- ondirty={markFieldsDirty}
1450
- onneedsaltchange={(n) => (heroNeedsAlt = { ...heroNeedsAlt, [field.name]: n })}
1451
- />
1452
- {:else}
1453
- <label class="flex flex-col gap-1">
1454
- <span class="text-sm font-medium">{field.label}</span>
1455
- <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} />
1456
- {#if field.description}
1457
- {@render fieldHint(field.name, field.description)}
1458
- {/if}
1459
- </label>
1460
- {/if}
1370
+ <FieldInput
1371
+ {field}
1372
+ frontmatter={data.frontmatter}
1373
+ targets={data.linkTargets}
1374
+ markFieldsDirty={markFieldsDirty}
1375
+ mediaLibrary={mediaLibrary}
1376
+ conceptId={data.conceptId}
1377
+ id={data.id}
1378
+ heroFieldRefs={heroFieldRefs}
1379
+ onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
1380
+ onheroneedsalt={(name, n) => (heroNeedsAlt = { ...heroNeedsAlt, [name]: n })}
1381
+ {icons}
1382
+ />
1461
1383
  {/each}
1462
1384
  </fieldset>
1463
1385
  {/if}
@@ -1,8 +1,7 @@
1
1
  import type { ComponentRegistry } from '../render/registry.js';
2
2
  import type { IconSet } from '../render/glyph.js';
3
3
  import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
4
- import type { LinkResolve } from '../content/links.js';
5
- import type { MediaResolve } from '../render/resolve-media.js';
4
+ import type { SiteRender } from '../content/types.js';
6
5
  interface Props {
7
6
  /** The edit load's data, plus the site name for the heading. */
8
7
  data: EditData & {
@@ -11,11 +10,7 @@ interface Props {
11
10
  /** The site's component registry, for the insert palette. */
12
11
  registry?: ComponentRegistry;
13
12
  /** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
14
- render?: (md: string, opts?: {
15
- stagger?: boolean;
16
- resolve?: LinkResolve;
17
- resolveMedia?: MediaResolve;
18
- }) => string | Promise<string>;
13
+ render?: SiteRender;
19
14
  /** The site's icon set, for the guided form's icon fields. */
20
15
  icons?: IconSet;
21
16
  /** The last content action's failure: the save guard's broken links, the delete guard's
@@ -0,0 +1,117 @@
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">let {
12
+ targets,
13
+ choose,
14
+ conceptFilter,
15
+ selectedIds = [],
16
+ trigger = true,
17
+ heading: dialogHeading = "Link to a page",
18
+ searchLabel = "Search pages and posts",
19
+ emptyText = "No pages or posts to link to."
20
+ } = $props();
21
+ let dialog = $state(null);
22
+ let searchInput = $state(null);
23
+ let query = $state("");
24
+ const ORDER = { pages: 0, posts: 1 };
25
+ function rank(concept) {
26
+ return ORDER[concept] ?? 2;
27
+ }
28
+ function heading(concept) {
29
+ if (concept === "pages") return "Pages";
30
+ if (concept === "posts") return "Posts";
31
+ return concept.charAt(0).toUpperCase() + concept.slice(1);
32
+ }
33
+ const groups = $derived.by(() => {
34
+ const q = query.trim().toLowerCase();
35
+ const scoped = conceptFilter ? targets.filter((t) => t.concept === conceptFilter) : targets;
36
+ const matched = q ? scoped.filter((t) => t.title.toLowerCase().includes(q)) : scoped;
37
+ const byConcept = /* @__PURE__ */ new Map();
38
+ for (const t of matched) {
39
+ const list = byConcept.get(t.concept) ?? [];
40
+ list.push(t);
41
+ byConcept.set(t.concept, list);
42
+ }
43
+ 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));
44
+ });
45
+ function isSelected(target) {
46
+ return selectedIds.includes(target.id);
47
+ }
48
+ export function open() {
49
+ query = "";
50
+ dialog?.showModal();
51
+ queueMicrotask(() => searchInput?.focus());
52
+ }
53
+ function close() {
54
+ dialog?.close();
55
+ }
56
+ function pick(target) {
57
+ if (isSelected(target)) return;
58
+ choose(target);
59
+ close();
60
+ }
61
+ </script>
62
+
63
+ {#if trigger}
64
+ <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Link to page" onclick={open}>
65
+ Link to page
66
+ </button>
67
+ {/if}
68
+
69
+ <dialog class="modal" aria-labelledby="cairn-entry-picker-title" bind:this={dialog}>
70
+ <div class="modal-box">
71
+ <div class="mb-3 flex items-center justify-between">
72
+ <h2 id="cairn-entry-picker-title" class="text-base font-semibold">{dialogHeading}</h2>
73
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
74
+ </div>
75
+
76
+ <input
77
+ type="search"
78
+ class="input input-bordered mb-3 w-full"
79
+ placeholder="Search by title"
80
+ aria-label={searchLabel}
81
+ bind:this={searchInput}
82
+ bind:value={query}
83
+ />
84
+
85
+ {#if groups.length === 0}
86
+ <p class="text-sm text-[var(--color-muted)]">{emptyText}</p>
87
+ {:else}
88
+ {#each groups as group (group.concept)}
89
+ <h3 class="mt-2 mb-1 text-xs font-semibold tracking-wide text-[var(--color-muted)] uppercase">{group.heading}</h3>
90
+ <ul class="menu w-full">
91
+ {#each group.items as target (`${target.concept}/${target.id}`)}
92
+ <li>
93
+ <button
94
+ type="button"
95
+ aria-disabled={isSelected(target)}
96
+ aria-label={isSelected(target) ? `${target.title} (already selected)` : target.title}
97
+ onclick={() => pick(target)}
98
+ >
99
+ <span class="flex flex-col items-start">
100
+ <span class="font-medium">{target.title}</span>
101
+ <span class="text-xs text-[var(--color-muted)]">
102
+ {#if isSelected(target)}<span class="badge badge-ghost badge-sm mr-1">Selected</span>{/if}
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>
@@ -0,0 +1,35 @@
1
+ import type { LinkTarget } from '../content/manifest.js';
2
+ interface Props {
3
+ /** The site's link targets, from the committed manifest (editLoad ships them). */
4
+ targets: LinkTarget[];
5
+ /** Called with the target the user picked; the host decides what to do with it. */
6
+ choose: (target: LinkTarget) => void;
7
+ /** Narrow the list to a single concept (the reference field's concept). */
8
+ conceptFilter?: string;
9
+ /** Ids the host already holds; matching rows render as already-selected and do not re-fire choose. */
10
+ selectedIds?: string[];
11
+ /** Render the built-in trigger button. False mounts only the dialog, for a host that supplies its
12
+ * own trigger and opens the dialog through the exported open(). */
13
+ trigger?: boolean;
14
+ /** The dialog title. Defaults to the editor link control's wording; a reference field passes a
15
+ * concept-appropriate heading. */
16
+ heading?: string;
17
+ /** The search input's accessible name. Defaults to the link control's wording. */
18
+ searchLabel?: string;
19
+ /** The empty-state text shown when no target matches. Defaults to the link control's wording. */
20
+ emptyText?: string;
21
+ }
22
+ /**
23
+ * The search + concept-grouped target list shared by the editor's "Link to page" control and the
24
+ * reference field picker. It lists link targets from the committed manifest, grouped by concept with
25
+ * Pages first then Posts then any other concept, each post showing its date and each draft marked, and
26
+ * fires choose() with the picked target. It knows nothing about cairn: tokens or the editor cursor; the
27
+ * host decides what a chosen target means. An optional conceptFilter narrows the list to one concept,
28
+ * and selectedIds marks rows the host already holds. Built on a native <dialog>, following the component
29
+ * dialog's a11y conventions.
30
+ */
31
+ declare const EntryPicker: import("svelte").Component<Props, {
32
+ open: () => void;
33
+ }, "">;
34
+ type EntryPicker = ReturnType<typeof EntryPicker>;
35
+ export default EntryPicker;
@@ -0,0 +1,218 @@
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">import MediaHeroField from "./MediaHeroField.svelte";
19
+ import ReferenceField from "./ReferenceField.svelte";
20
+ import ObjectGroupField from "./ObjectGroupField.svelte";
21
+ import RepeatableField from "./RepeatableField.svelte";
22
+ import IconPicker from "./IconPicker.svelte";
23
+ import { isClosedMultiselect } from "../content/frontmatter.js";
24
+ let {
25
+ field,
26
+ name = field.name,
27
+ frontmatter,
28
+ targets,
29
+ markFieldsDirty,
30
+ mediaLibrary,
31
+ conceptId,
32
+ id,
33
+ heroFieldRefs,
34
+ onuploaded,
35
+ onheroneedsalt,
36
+ icons
37
+ } = $props();
38
+ function str(v) {
39
+ return v == null ? "" : String(v);
40
+ }
41
+ function inputType(fieldType) {
42
+ switch (fieldType) {
43
+ case "url":
44
+ return "url";
45
+ case "email":
46
+ return "email";
47
+ case "datetime":
48
+ return "datetime-local";
49
+ default:
50
+ return void 0;
51
+ }
52
+ }
53
+ const DATE_PUBLISH_HINT = "Sets the date for this post. Publishing is a separate step you choose.";
54
+ </script>
55
+
56
+ {#snippet fieldHint(hintName: string, text: string)}
57
+ <p id={`${hintName}-hint`} class="fld-hint mt-1 text-sm text-[var(--color-muted)]">
58
+ {text}
59
+ </p>
60
+ {/snippet}
61
+
62
+ {#if field.type === 'textarea'}
63
+ {@const f = field as NamedField & TextareaField}
64
+ <label class="flex flex-col gap-1">
65
+ <span class="text-sm font-medium">{f.label}</span>
66
+ <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>
67
+ {#if f.help}
68
+ {@render fieldHint(f.name, f.help)}
69
+ {/if}
70
+ </label>
71
+ {:else if field.type === 'number'}
72
+ {@const f = field as NamedField & NumberField}
73
+ <label class="flex flex-col gap-1">
74
+ <span class="text-sm font-medium">{f.label}</span>
75
+ <input
76
+ class="input input-sm"
77
+ type="number"
78
+ {name}
79
+ aria-label={f.label}
80
+ aria-describedby={f.help ? `${f.name}-hint` : undefined}
81
+ min={f.min}
82
+ max={f.max}
83
+ step={f.integer ? 1 : undefined}
84
+ value={str(frontmatter[f.name])}
85
+ required={f.required}
86
+ />
87
+ {#if f.help}
88
+ {@render fieldHint(f.name, f.help)}
89
+ {/if}
90
+ </label>
91
+ {:else if field.type === 'select'}
92
+ {@const f = field as NamedField & SelectField}
93
+ <label class="flex flex-col gap-1">
94
+ <span class="text-sm font-medium">{f.label}</span>
95
+ <select class="select select-sm" {name} aria-label={f.label} aria-describedby={f.help ? `${f.name}-hint` : undefined} required={f.required}>
96
+ <!-- A leading empty option submits '' (the key is dropped on save); a required select
97
+ leaves it unselected so an unset value fails the required check with a clear message. -->
98
+ <option value="">&mdash; none &mdash;</option>
99
+ {#each f.options as option (option)}
100
+ <option value={option} selected={str(frontmatter[f.name]) === option}>{option}</option>
101
+ {/each}
102
+ </select>
103
+ {#if f.help}
104
+ {@render fieldHint(f.name, f.help)}
105
+ {/if}
106
+ </label>
107
+ {:else if field.type === 'date'}
108
+ <label class="flex flex-col gap-1">
109
+ <span class="text-sm font-medium">{field.label}</span>
110
+ <!-- A date field always carries a hint: the adapter's help when set, else the
111
+ built-in publish-clarity default. So aria-describedby always points at the paragraph. -->
112
+ <input class="input input-sm" type="date" {name} aria-label={field.label} aria-describedby={`${field.name}-hint`} value={str(frontmatter[field.name])} />
113
+ {@render fieldHint(field.name, field.help || DATE_PUBLISH_HINT)}
114
+ </label>
115
+ {:else if field.type === 'boolean'}
116
+ <div class="flex flex-col gap-1">
117
+ <label class="label cursor-pointer justify-start gap-2">
118
+ <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} />
119
+ <span class="text-sm">{field.label}</span>
120
+ </label>
121
+ {#if field.help}
122
+ {@render fieldHint(field.name, field.help)}
123
+ {/if}
124
+ </div>
125
+ {:else if field.type === 'multiselect' && isClosedMultiselect(field)}
126
+ {@const f = field as NamedField & MultiselectField & { options: readonly string[] }}
127
+ {@const selected = (frontmatter[f.name] ?? []) as string[]}
128
+ <fieldset class="fieldset" aria-describedby={f.help ? `${f.name}-hint` : undefined}>
129
+ <legend class="fieldset-legend">{f.label}</legend>
130
+ {#if f.help}
131
+ {@render fieldHint(f.name, f.help)}
132
+ {/if}
133
+ <div class="flex flex-wrap gap-2">
134
+ {#each f.options as option (option)}
135
+ <label class="label cursor-pointer justify-start gap-2">
136
+ <input
137
+ class="checkbox checkbox-sm"
138
+ type="checkbox"
139
+ {name}
140
+ value={option}
141
+ checked={selected.includes(option)}
142
+ />
143
+ <span class="text-sm">{option}</span>
144
+ </label>
145
+ {/each}
146
+ </div>
147
+ </fieldset>
148
+ {:else if field.type === 'multiselect'}
149
+ {@const f = field as NamedField & MultiselectField}
150
+ {@const tagValue = ((frontmatter[f.name] ?? []) as string[]).join(', ')}
151
+ <label class="flex flex-col gap-1">
152
+ <span class="text-sm font-medium">{f.label}</span>
153
+ <input
154
+ class="input input-sm"
155
+ {name}
156
+ aria-label={f.label}
157
+ aria-describedby={f.help ? `${f.name}-hint` : undefined}
158
+ placeholder={f.placeholder ?? (f.help ? undefined : 'Separate values with commas')}
159
+ value={tagValue}
160
+ />
161
+ {#if f.help}
162
+ {@render fieldHint(f.name, f.help)}
163
+ {/if}
164
+ </label>
165
+ {:else if field.type === 'image'}
166
+ {@const heroValue = frontmatter[field.name] as ImageValue | undefined}
167
+ <!-- The binding_property_non_reactive warning this logs is benign: the parent owns the $state
168
+ proxy and mutates it by reference, and the hero-alt focus flow reads the same prefixed key. -->
169
+ <MediaHeroField
170
+ bind:this={heroFieldRefs[name]}
171
+ field={{ name, label: field.label }}
172
+ value={heroValue}
173
+ decorative={heroValue?.decorative ?? false}
174
+ lead={field.type === 'image' && field.seo === true}
175
+ mediaLibrary={mediaLibrary}
176
+ conceptId={conceptId}
177
+ id={id}
178
+ onuploaded={onuploaded}
179
+ ondirty={markFieldsDirty}
180
+ onneedsaltchange={(n) => onheroneedsalt(name, n)}
181
+ />
182
+ {:else if field.type === 'reference'}
183
+ <ReferenceField {field} value={(frontmatter[field.name] ?? '') as string} {targets} ondirty={markFieldsDirty} />
184
+ {:else if field.type === 'array' && field.item.type === 'reference'}
185
+ <ReferenceField {field} value={(frontmatter[field.name] ?? []) as string[]} {targets} ondirty={markFieldsDirty} />
186
+ {:else if field.type === 'object'}
187
+ <ObjectGroupField {field} {name} frontmatter={(frontmatter[field.name] ?? {}) as Record<string, unknown>} {markFieldsDirty} {mediaLibrary} {conceptId} {id} {heroFieldRefs} {targets} {onuploaded} {onheroneedsalt} {icons} />
188
+ {:else if field.type === 'array' && field.item.type !== 'reference'}
189
+ <RepeatableField {field} {name} rows={(frontmatter[field.name] ?? []) as unknown[]} {markFieldsDirty} {mediaLibrary} {conceptId} {id} {heroFieldRefs} {targets} {onuploaded} {onheroneedsalt} {icons} />
190
+ {:else if field.type === 'icon' && icons}
191
+ <div class="flex flex-col gap-1">
192
+ <span class="text-sm font-medium">{field.label}</span>
193
+ <IconPicker
194
+ {icons}
195
+ label={field.label}
196
+ describedby={field.help ? `${field.name}-hint` : undefined}
197
+ value={typeof frontmatter[field.name] === 'string' ? (frontmatter[field.name] as string) : ''}
198
+ required={field.required ?? false}
199
+ onChange={(glyph) => {
200
+ frontmatter[field.name] = glyph;
201
+ markFieldsDirty();
202
+ }}
203
+ />
204
+ {#if field.help}
205
+ {@render fieldHint(field.name, field.help)}
206
+ {/if}
207
+ </div>
208
+ {:else}
209
+ <!-- The plain single-line text input arm: url, email, datetime, and the text fallback. They share
210
+ one shape and differ only in the input type inputType() resolves. -->
211
+ <label class="flex flex-col gap-1">
212
+ <span class="text-sm font-medium">{field.label}</span>
213
+ <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} />
214
+ {#if field.help}
215
+ {@render fieldHint(field.name, field.help)}
216
+ {/if}
217
+ </label>
218
+ {/if}
@@ -0,0 +1,51 @@
1
+ import MediaHeroField from './MediaHeroField.svelte';
2
+ import type { NamedField } from '../content/types.js';
3
+ import type { IconSet } from '../render/glyph.js';
4
+ import type { LinkTarget } from '../content/manifest.js';
5
+ import type { MediaEntry } from '../media/manifest.js';
6
+ import type { MediaLibraryEntry } from '../media/library-entry.js';
7
+ interface Props {
8
+ /** The leaf field to render; its `name` is the frontmatter key the arm reads its value from. */
9
+ field: NamedField;
10
+ /** The form input name. Defaults to `field.name`; a container caller passes a prefixed path. */
11
+ name?: string;
12
+ /** The frontmatter slice this field reads from, keyed by `field.name`. */
13
+ frontmatter: Record<string, unknown>;
14
+ /** The site link targets the reference arm offers. */
15
+ targets: LinkTarget[];
16
+ /** Mark the edit form dirty; the image arm wires it to the hero field's commit. */
17
+ markFieldsDirty: () => void;
18
+ /** The merged committed-plus-uploaded media library, keyed by content hash. */
19
+ mediaLibrary: Record<string, MediaLibraryEntry>;
20
+ /** The concept the entry belongs to (the upload action's route param). */
21
+ conceptId: string;
22
+ /** The entry id (the upload action's route param). */
23
+ id: string;
24
+ /** The host's hero-field refs, keyed by the prefixed `name` so two rows do not collide. */
25
+ heroFieldRefs: Record<string, MediaHeroField>;
26
+ /** Called with the server-owned record on a successful upload, so the host merges it. */
27
+ onuploaded: (record: MediaEntry) => void;
28
+ /** Called when a hero's needs-alt status changes, keyed by the prefixed `name`. */
29
+ onheroneedsalt: (name: string, needsAlt: boolean) => void;
30
+ /** The site's icon set, threaded to the icon arm's picker. Absent when the site ships none. */
31
+ icons?: IconSet;
32
+ }
33
+ /**
34
+ * The leaf-field dispatcher for the edit page's Details panel. It renders one leaf field (a scalar,
35
+ * an image, or a reference) as the matching input, picked by `field.type`.
36
+ *
37
+ * It is name-prefixable so a container row can reuse it one level down. The `name` prop is the form
38
+ * input name: it defaults to `field.name` at the top level, and a container caller passes a prefixed
39
+ * path (`${parent}.${index}` for an array element, `${parent}.${leafKey}` for an object leaf) so the
40
+ * nested decode in `frontmatter.ts` reads the value back from the right slot. Each arm reads its value
41
+ * from `frontmatter[field.name]`, so a nested caller passes the row or object slice as `frontmatter`
42
+ * and the leaf key as `field.name`, leaving the reads unchanged.
43
+ *
44
+ * An `object` field renders a labeled `ObjectGroupField`, and a non-reference `array` renders a
45
+ * `RepeatableField`; both recurse one level back through this dispatcher for their leaves, which the
46
+ * one-level nesting cap (the declaration guard) bounds so the recursion terminates. An
47
+ * `array(reference)` stays on `ReferenceField`.
48
+ */
49
+ declare const FieldInput: import("svelte").Component<Props, {}, "">;
50
+ type FieldInput = ReturnType<typeof FieldInput>;
51
+ export default FieldInput;
@@ -7,7 +7,7 @@ keys move the selection, the standard radiogroup keyboard model. The glyph rende
7
7
  IconSet path data, matching the renderer's 256-unit viewBox.
8
8
  -->
9
9
  <script lang="ts">import { tick } from "svelte";
10
- let { icons, value, required, onChange, label = "Icon" } = $props();
10
+ let { icons, value, required, onChange, label = "Icon", describedby } = $props();
11
11
  let group;
12
12
  const names = $derived(Object.keys(icons));
13
13
  const choices = $derived(required ? names : ["", ...names]);
@@ -32,7 +32,7 @@ function onKeydown(e) {
32
32
  }
33
33
  </script>
34
34
 
35
- <div class="flex flex-wrap gap-2" role="radiogroup" aria-label={label} bind:this={group}>
35
+ <div class="flex flex-wrap gap-2" role="radiogroup" aria-label={label} aria-required={required ? 'true' : undefined} aria-describedby={describedby} bind:this={group}>
36
36
  {#if !required}
37
37
  <button
38
38
  type="button"
@@ -10,6 +10,8 @@ interface Props {
10
10
  onChange: (name: string) => void;
11
11
  /** The group's accessible name, threaded from the field label. Defaults to Icon. */
12
12
  label?: string;
13
+ /** The id of a describing element (a field hint), for the radiogroup's `aria-describedby`. */
14
+ describedby?: string;
13
15
  }
14
16
  /**
15
17
  * A visual icon choice over the site's IconSet. The choices form a radiogroup; each glyph is a radio