@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.
- package/CHANGELOG.md +82 -0
- package/dist/ambient.d.ts +2 -0
- package/dist/components/CairnAdmin.svelte.d.ts +2 -7
- package/dist/components/ComponentForm.svelte +44 -27
- package/dist/components/ComponentInsertDialog.svelte +5 -5
- package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
- 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 +4 -0
- 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.js +3 -4
- package/dist/content/fields.d.ts +49 -1
- package/dist/content/fields.js +11 -0
- package/dist/content/fieldset.d.ts +31 -10
- package/dist/content/fieldset.js +262 -109
- 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 +2 -5
- package/dist/delivery/public-routes.js +15 -1
- 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 +16 -12
- package/dist/index.js +7 -8
- 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/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/pipeline.d.ts +0 -6
- package/dist/render/pipeline.js +1 -1
- package/dist/render/registry.d.ts +34 -34
- package/dist/render/registry.js +26 -5
- package/dist/render/rehype-dispatch.d.ts +4 -4
- package/dist/render/rehype-dispatch.js +36 -11
- package/dist/render/remark-directives.js +4 -5
- package/dist/render/sanitize-schema.js +1 -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/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 +5 -1
- package/src/lib/ambient.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 +9 -8
- 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 +3 -4
- package/src/lib/content/fields.ts +52 -1
- package/src/lib/content/fieldset.ts +291 -128
- 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 +17 -3
- 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 +38 -23
- package/src/lib/islands/index.ts +84 -0
- package/src/lib/islands/types.ts +11 -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/pipeline.ts +1 -7
- package/src/lib/render/registry.ts +58 -39
- package/src/lib/render/rehype-dispatch.ts +45 -10
- package/src/lib/render/remark-directives.ts +4 -5
- package/src/lib/render/sanitize-schema.ts +1 -1
- package/src/lib/sveltekit/cairn-admin.ts +8 -9
- package/src/lib/sveltekit/content-routes.ts +330 -221
- 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 -85
- package/dist/content/validate.d.ts +0 -17
- package/dist/content/validate.js +0 -93
- package/src/lib/content/schema.ts +0 -163
- 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
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
-
|
|
1888
|
-
{
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
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="">— none —</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}
|