@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.
- package/CHANGELOG.md +216 -0
- package/dist/ambient.d.ts +2 -0
- package/dist/auth/types.d.ts +7 -0
- package/dist/components/CairnAdmin.svelte.d.ts +2 -7
- package/dist/components/ComponentForm.svelte +44 -27
- package/dist/components/ComponentInsertDialog.svelte +22 -11
- package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
- package/dist/components/ConceptList.svelte +25 -4
- package/dist/components/EditPage.svelte +29 -107
- package/dist/components/EditPage.svelte.d.ts +2 -7
- package/dist/components/EntryPicker.svelte +117 -0
- package/dist/components/EntryPicker.svelte.d.ts +35 -0
- package/dist/components/FieldInput.svelte +218 -0
- package/dist/components/FieldInput.svelte.d.ts +51 -0
- package/dist/components/IconPicker.svelte +2 -2
- package/dist/components/IconPicker.svelte.d.ts +2 -0
- package/dist/components/LinkPicker.svelte +8 -75
- package/dist/components/LinkPicker.svelte.d.ts +4 -5
- package/dist/components/MediaHeroField.svelte +8 -5
- package/dist/components/MediaHeroField.svelte.d.ts +4 -0
- package/dist/components/ObjectGroupField.svelte +54 -0
- package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
- package/dist/components/ReferenceField.svelte +94 -0
- package/dist/components/ReferenceField.svelte.d.ts +27 -0
- package/dist/components/RepeatableField.svelte +221 -0
- package/dist/components/RepeatableField.svelte.d.ts +53 -0
- package/dist/components/cairn-admin.css +179 -2
- package/dist/components/preview-doc.js +5 -1
- package/dist/components/tidy-validate.js +1 -1
- package/dist/content/adapter.js +18 -0
- package/dist/content/advisories.d.ts +2 -2
- package/dist/content/advisories.js +3 -5
- package/dist/content/compose.d.ts +7 -6
- package/dist/content/compose.js +26 -20
- package/dist/content/concepts.d.ts +21 -15
- package/dist/content/concepts.js +55 -32
- package/dist/content/field-rules.d.ts +15 -0
- package/dist/content/field-rules.js +38 -0
- package/dist/content/fields.d.ts +169 -0
- package/dist/content/fields.js +41 -0
- package/dist/content/fieldset.d.ts +107 -0
- package/dist/content/fieldset.js +386 -0
- package/dist/content/frontmatter-region.d.ts +38 -0
- package/dist/content/frontmatter-region.js +75 -0
- package/dist/content/frontmatter.d.ts +35 -2
- package/dist/content/frontmatter.js +232 -11
- package/dist/content/manifest.d.ts +34 -0
- package/dist/content/manifest.js +80 -4
- package/dist/content/media-refs.d.ts +2 -2
- package/dist/content/media-rewrite.js +1 -69
- package/dist/content/reference-index.d.ts +56 -0
- package/dist/content/reference-index.js +95 -0
- package/dist/content/references.d.ts +40 -0
- package/dist/content/references.js +0 -0
- package/dist/content/standard-schema.d.ts +30 -0
- package/dist/content/standard-schema.js +4 -0
- package/dist/content/types.d.ts +127 -178
- package/dist/delivery/data.d.ts +2 -2
- package/dist/delivery/data.js +1 -1
- package/dist/delivery/public-routes.d.ts +10 -5
- package/dist/delivery/public-routes.js +25 -2
- package/dist/delivery/site-descriptors.d.ts +5 -1
- package/dist/delivery/site-descriptors.js +8 -3
- package/dist/delivery/site-indexes.d.ts +2 -2
- package/dist/delivery/site-resolver.d.ts +25 -0
- package/dist/delivery/site-resolver.js +49 -0
- package/dist/doctor/checks-local.js +6 -11
- package/dist/github/backend.d.ts +83 -0
- package/dist/github/backend.js +76 -0
- package/dist/github/credentials.d.ts +11 -5
- package/dist/github/credentials.js +3 -3
- package/dist/github/repo.d.ts +8 -19
- package/dist/github/repo.js +69 -80
- package/dist/github/types.d.ts +1 -1
- package/dist/github/types.js +4 -4
- package/dist/index.d.ts +18 -10
- package/dist/index.js +9 -5
- package/dist/islands/index.d.ts +12 -0
- package/dist/islands/index.js +83 -0
- package/dist/islands/types.d.ts +7 -0
- package/dist/islands/types.js +1 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/manifest.d.ts +11 -0
- package/dist/media/manifest.js +13 -0
- package/dist/media/rewrite-plan.d.ts +2 -3
- package/dist/media/rewrite-plan.js +2 -3
- package/dist/media/usage.d.ts +2 -2
- package/dist/media/usage.js +3 -5
- package/dist/nav/site-config.d.ts +0 -6
- package/dist/nav/site-config.js +6 -4
- package/dist/render/component-grammar.js +11 -11
- package/dist/render/component-reference.js +5 -3
- package/dist/render/component-validate.d.ts +4 -1
- package/dist/render/component-validate.js +10 -35
- package/dist/render/highlight.d.ts +9 -0
- package/dist/render/highlight.js +206 -0
- package/dist/render/pipeline.d.ts +0 -6
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.d.ts +44 -36
- package/dist/render/registry.js +47 -6
- package/dist/render/rehype-dispatch.d.ts +6 -10
- package/dist/render/rehype-dispatch.js +38 -17
- package/dist/render/remark-directives.js +4 -5
- package/dist/render/sanitize-schema.d.ts +10 -0
- package/dist/render/sanitize-schema.js +30 -1
- package/dist/sveltekit/cairn-admin.d.ts +5 -5
- package/dist/sveltekit/cairn-admin.js +3 -4
- package/dist/sveltekit/content-routes.d.ts +10 -8
- package/dist/sveltekit/content-routes.js +269 -181
- package/dist/sveltekit/guard.js +10 -0
- package/dist/sveltekit/health.d.ts +7 -3
- package/dist/sveltekit/health.js +9 -3
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/nav-routes.d.ts +6 -5
- package/dist/sveltekit/nav-routes.js +22 -20
- package/dist/sveltekit/types.d.ts +2 -0
- package/dist/vite/index.d.ts +3 -3
- package/dist/vite/index.js +17 -8
- package/package.json +17 -2
- package/src/lib/ambient.ts +7 -0
- package/src/lib/auth/types.ts +7 -0
- package/src/lib/components/CairnAdmin.svelte +2 -6
- package/src/lib/components/ComponentForm.svelte +48 -27
- package/src/lib/components/ComponentInsertDialog.svelte +26 -14
- package/src/lib/components/ConceptList.svelte +41 -4
- package/src/lib/components/EditPage.svelte +43 -119
- package/src/lib/components/EntryPicker.svelte +154 -0
- package/src/lib/components/FieldInput.svelte +262 -0
- package/src/lib/components/IconPicker.svelte +4 -2
- package/src/lib/components/LinkPicker.svelte +10 -81
- package/src/lib/components/MediaHeroField.svelte +12 -5
- package/src/lib/components/ObjectGroupField.svelte +97 -0
- package/src/lib/components/ReferenceField.svelte +126 -0
- package/src/lib/components/RepeatableField.svelte +310 -0
- package/src/lib/components/preview-doc.ts +5 -1
- package/src/lib/components/tidy-validate.ts +1 -1
- package/src/lib/content/adapter.ts +21 -0
- package/src/lib/content/advisories.ts +4 -7
- package/src/lib/content/compose.ts +30 -23
- package/src/lib/content/concepts.ts +68 -40
- package/src/lib/content/field-rules.ts +39 -0
- package/src/lib/content/fields.ts +178 -0
- package/src/lib/content/fieldset.ts +470 -0
- package/src/lib/content/frontmatter-region.ts +90 -0
- package/src/lib/content/frontmatter.ts +231 -15
- package/src/lib/content/manifest.ts +101 -4
- package/src/lib/content/media-refs.ts +2 -2
- package/src/lib/content/media-rewrite.ts +7 -80
- package/src/lib/content/reference-index.ts +159 -0
- package/src/lib/content/references.ts +0 -0
- package/src/lib/content/standard-schema.ts +25 -0
- package/src/lib/content/types.ts +128 -195
- package/src/lib/delivery/data.ts +2 -2
- package/src/lib/delivery/public-routes.ts +36 -4
- package/src/lib/delivery/site-descriptors.ts +8 -3
- package/src/lib/delivery/site-indexes.ts +2 -2
- package/src/lib/delivery/site-resolver.ts +64 -0
- package/src/lib/doctor/checks-local.ts +6 -14
- package/src/lib/github/backend.ts +161 -0
- package/src/lib/github/credentials.ts +10 -7
- package/src/lib/github/repo.ts +79 -83
- package/src/lib/github/types.ts +5 -5
- package/src/lib/index.ts +40 -18
- package/src/lib/islands/index.ts +84 -0
- package/src/lib/islands/types.ts +11 -0
- package/src/lib/log/events.ts +1 -0
- package/src/lib/media/index.ts +1 -0
- package/src/lib/media/manifest.ts +14 -0
- package/src/lib/media/rewrite-plan.ts +4 -6
- package/src/lib/media/usage.ts +4 -7
- package/src/lib/nav/site-config.ts +8 -9
- package/src/lib/render/component-grammar.ts +10 -10
- package/src/lib/render/component-reference.ts +4 -3
- package/src/lib/render/component-validate.ts +10 -35
- package/src/lib/render/highlight.ts +259 -0
- package/src/lib/render/pipeline.ts +13 -8
- package/src/lib/render/registry.ts +88 -42
- package/src/lib/render/rehype-dispatch.ts +47 -16
- package/src/lib/render/remark-directives.ts +4 -5
- package/src/lib/render/sanitize-schema.ts +32 -1
- package/src/lib/sveltekit/cairn-admin.ts +8 -9
- package/src/lib/sveltekit/content-routes.ts +330 -221
- package/src/lib/sveltekit/guard.ts +15 -0
- package/src/lib/sveltekit/health.ts +13 -6
- package/src/lib/sveltekit/index.ts +2 -2
- package/src/lib/sveltekit/nav-routes.ts +33 -29
- package/src/lib/sveltekit/types.ts +5 -1
- package/src/lib/vite/index.ts +20 -11
- package/dist/content/schema.d.ts +0 -87
- package/dist/content/schema.js +0 -89
- package/dist/content/validate.d.ts +0 -17
- package/dist/content/validate.js +0 -93
- package/src/lib/content/schema.ts +0 -167
- 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
|
|
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
|
|
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,
|
|
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
|
-
|
|
1372
|
-
{
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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 {
|
|
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?:
|
|
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="">— none —</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
|