@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The reference field editor arm. A single `reference` renders a combobox-style button showing the
|
|
4
|
+
current target's resolved title (looked up in the site's link targets), opening EntryPicker scoped to
|
|
5
|
+
the field's concept; on pick it sets the value and emits one hidden input the decoder reads. A many
|
|
6
|
+
`array(reference)` renders a removable chip list, each chip showing the target's resolved title, plus
|
|
7
|
+
an EntryPicker that marks the already-held ids and adds another; it emits one hidden input per selected
|
|
8
|
+
id, so frontmatterFromForm's getAll reads them all. EntryPicker owns the search and grouped list; this
|
|
9
|
+
component owns the cardinality, the chips, and the hidden inputs the form submits.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { untrack } from 'svelte';
|
|
13
|
+
import type { LinkTarget } from '../content/manifest.js';
|
|
14
|
+
import type { ReferenceField } from '../content/fields.js';
|
|
15
|
+
import type { NamedField } from '../content/types.js';
|
|
16
|
+
import EntryPicker from './EntryPicker.svelte';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
/** The reference or array(reference) descriptor this arm renders. */
|
|
20
|
+
field: NamedField;
|
|
21
|
+
/** The current value: one id for a single reference, a list of ids for an array. */
|
|
22
|
+
value: string | string[];
|
|
23
|
+
/** The site's link targets, from the committed manifest (editLoad ships them). */
|
|
24
|
+
targets: LinkTarget[];
|
|
25
|
+
/** Called when the committed ids change (a pick, an add, or a remove), so the host sets
|
|
26
|
+
* fieldsDirty. The hidden-input writes do not fire the form's oninput, so the field signals
|
|
27
|
+
* dirty explicitly, the same way MediaHeroField does. */
|
|
28
|
+
ondirty?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let { field, value, targets, ondirty }: Props = $props();
|
|
32
|
+
|
|
33
|
+
// The descriptor's concept, read from the single reference or the array's reference item, so the
|
|
34
|
+
// picker scopes to the right concept and a chip resolves its title within that concept's targets.
|
|
35
|
+
// Narrow on field.type so the access needs no cast; the array item is always a reference here
|
|
36
|
+
// (fieldset's checkArrayItems enforces it at declaration), so the one cast names that guarantee.
|
|
37
|
+
const concept = $derived.by(() => {
|
|
38
|
+
if (field.type === 'array') return (field.item as ReferenceField).concept;
|
|
39
|
+
if (field.type === 'reference') return field.concept;
|
|
40
|
+
return '';
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// The single reference's current id, seeded once from the prop and owned thereafter (untrack marks
|
|
44
|
+
// the read a deliberate one-time seed, not a reactive dependency). Updated on pick.
|
|
45
|
+
let singleId = $state(untrack(() => (typeof value === 'string' ? value : '')));
|
|
46
|
+
// The array's current ids, seeded once from the prop and updated as chips are added and removed.
|
|
47
|
+
let ids = $state<string[]>(untrack(() => (Array.isArray(value) ? [...value] : [])));
|
|
48
|
+
|
|
49
|
+
// The headless picker; this component drives it so it can carry the concept filter and the held ids.
|
|
50
|
+
let picker = $state<{ open: () => void } | null>(null);
|
|
51
|
+
|
|
52
|
+
/** Resolve an id to its target title within this field's concept, falling back to the bare id. */
|
|
53
|
+
function titleFor(id: string): string {
|
|
54
|
+
return targets.find((t) => t.concept === concept && t.id === id)?.title ?? id;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function chooseSingle(target: LinkTarget) {
|
|
58
|
+
singleId = target.id;
|
|
59
|
+
ondirty?.();
|
|
60
|
+
}
|
|
61
|
+
function chooseMany(target: LinkTarget) {
|
|
62
|
+
if (!ids.includes(target.id)) {
|
|
63
|
+
ids = [...ids, target.id];
|
|
64
|
+
ondirty?.();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function remove(id: string) {
|
|
68
|
+
ids = ids.filter((x) => x !== id);
|
|
69
|
+
ondirty?.();
|
|
70
|
+
}
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
{#if field.type === 'array'}
|
|
74
|
+
<fieldset class="m-0 flex min-w-0 flex-col gap-2 border-0 p-0">
|
|
75
|
+
<legend class="text-sm font-medium">{field.label}</legend>
|
|
76
|
+
{#if ids.length}
|
|
77
|
+
<ul class="flex flex-wrap gap-2">
|
|
78
|
+
{#each ids as id (id)}
|
|
79
|
+
<li class="badge badge-ghost gap-1">
|
|
80
|
+
<span>{titleFor(id)}</span>
|
|
81
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label={`Remove ${titleFor(id)}`} onclick={() => remove(id)}>
|
|
82
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
|
83
|
+
</button>
|
|
84
|
+
<input type="hidden" name={field.name} value={id} />
|
|
85
|
+
</li>
|
|
86
|
+
{/each}
|
|
87
|
+
</ul>
|
|
88
|
+
{/if}
|
|
89
|
+
<div>
|
|
90
|
+
<button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label={`Add ${field.label}`} onclick={() => picker?.open()}>
|
|
91
|
+
Add {field.label}
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
</fieldset>
|
|
95
|
+
<EntryPicker
|
|
96
|
+
bind:this={picker}
|
|
97
|
+
{targets}
|
|
98
|
+
choose={chooseMany}
|
|
99
|
+
conceptFilter={concept}
|
|
100
|
+
selectedIds={ids}
|
|
101
|
+
trigger={false}
|
|
102
|
+
heading={`Choose ${field.label}`}
|
|
103
|
+
searchLabel={`Search ${concept}`}
|
|
104
|
+
emptyText={`No ${concept} to choose.`}
|
|
105
|
+
/>
|
|
106
|
+
{:else}
|
|
107
|
+
<div class="flex flex-col gap-1">
|
|
108
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
109
|
+
<button type="button" class="btn btn-sm btn-ghost justify-start" aria-haspopup="dialog" aria-label={field.label} onclick={() => picker?.open()}>
|
|
110
|
+
{#if singleId}{titleFor(singleId)}{:else}<span class="text-[var(--color-muted)]">Choose {field.label}</span>{/if}
|
|
111
|
+
</button>
|
|
112
|
+
{#if singleId}
|
|
113
|
+
<input type="hidden" name={field.name} value={singleId} />
|
|
114
|
+
{/if}
|
|
115
|
+
</div>
|
|
116
|
+
<EntryPicker
|
|
117
|
+
bind:this={picker}
|
|
118
|
+
{targets}
|
|
119
|
+
choose={chooseSingle}
|
|
120
|
+
conceptFilter={concept}
|
|
121
|
+
trigger={false}
|
|
122
|
+
heading={`Choose ${field.label}`}
|
|
123
|
+
searchLabel={`Search ${concept}`}
|
|
124
|
+
emptyText={`No ${concept} to choose.`}
|
|
125
|
+
/>
|
|
126
|
+
{/if}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The repeatable-row editor, the arm for a non-reference `array` container. It renders a list of rows,
|
|
4
|
+
each row either a single leaf (`array(text)`, `array(image)`) or a flat object group
|
|
5
|
+
(`array(object({...}))`), with keyboard-operable add, remove, and reorder. Each row collapses to its
|
|
6
|
+
`itemLabel` summary and expands to edit, the same buries-fewer-fields move the Details panel makes.
|
|
7
|
+
|
|
8
|
+
Rows are wrapped in a `{ id, value }` envelope so node identity follows a row through a reorder or a
|
|
9
|
+
remove and an in-progress edit (or the keyboard focus) never jumps to the wrong row. The id is a
|
|
10
|
+
seed-time counter, not a random uuid, so the server and client agree at hydration. The envelope is
|
|
11
|
+
UI-only; the form names derive from each row's CURRENT position (`${name}.${i}`), so the Task 3
|
|
12
|
+
decoder reads a compact, ordered set. The component seeds once from `rows`; the `{#key entryKey}`
|
|
13
|
+
wrapper in EditPage remounts it on an entry change, so it adds no re-seed effect.
|
|
14
|
+
|
|
15
|
+
A structural mutation (add, remove, reorder) marks the form dirty, because those do not fire the
|
|
16
|
+
form's `oninput`; a leaf edit inside a row does not, because the row inputs sit inside the edit form
|
|
17
|
+
whose `oninput` bubbles. An always-mounted polite live region announces add and remove.
|
|
18
|
+
-->
|
|
19
|
+
<script lang="ts">
|
|
20
|
+
import { tick, untrack } from 'svelte';
|
|
21
|
+
import { sortItems } from '@rodrigodagostino/svelte-sortable-list';
|
|
22
|
+
import FieldInput from './FieldInput.svelte';
|
|
23
|
+
import ObjectGroupField from './ObjectGroupField.svelte';
|
|
24
|
+
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
|
25
|
+
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
|
26
|
+
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
|
27
|
+
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
|
28
|
+
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
|
29
|
+
import PlusIcon from '@lucide/svelte/icons/plus';
|
|
30
|
+
import type { NamedField } from '../content/types.js';
|
|
31
|
+
import type { ArrayField, ObjectField } from '../content/fields.js';
|
|
32
|
+
import type { LinkTarget } from '../content/manifest.js';
|
|
33
|
+
import type { MediaEntry } from '../media/manifest.js';
|
|
34
|
+
import type { MediaLibraryEntry } from '../media/library-entry.js';
|
|
35
|
+
import type { IconSet } from '../render/glyph.js';
|
|
36
|
+
import type MediaHeroField from './MediaHeroField.svelte';
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
/** The array descriptor to render; its `item` is the per-row leaf or flat object. */
|
|
40
|
+
field: NamedField & ArrayField;
|
|
41
|
+
/** The form name prefix for this list; each row renders at `${name}.${i}`. */
|
|
42
|
+
name: string;
|
|
43
|
+
/** The seed rows: a list of leaf values, or a list of object slices for an object item. */
|
|
44
|
+
rows: unknown[];
|
|
45
|
+
/** The site link targets the reference arm offers (threaded through to each row). */
|
|
46
|
+
targets: LinkTarget[];
|
|
47
|
+
/** Mark the edit form dirty; called on add, remove, and reorder (these skip the form's oninput). */
|
|
48
|
+
markFieldsDirty: () => void;
|
|
49
|
+
/** The merged committed-plus-uploaded media library, keyed by content hash. */
|
|
50
|
+
mediaLibrary: Record<string, MediaLibraryEntry>;
|
|
51
|
+
/** The concept the entry belongs to (the upload action's route param). */
|
|
52
|
+
conceptId: string;
|
|
53
|
+
/** The entry id (the upload action's route param). */
|
|
54
|
+
id: string;
|
|
55
|
+
/** The host's hero-field refs, keyed by the prefixed `name` so two rows do not collide. */
|
|
56
|
+
heroFieldRefs: Record<string, MediaHeroField>;
|
|
57
|
+
/** Called with the server-owned record on a successful upload, so the host merges it. */
|
|
58
|
+
onuploaded: (record: MediaEntry) => void;
|
|
59
|
+
/** Called when a hero's needs-alt status changes, keyed by the prefixed `name`. */
|
|
60
|
+
onheroneedsalt: (name: string, needsAlt: boolean) => void;
|
|
61
|
+
/** The site's icon set, forwarded to each row's icon arm. */
|
|
62
|
+
icons?: IconSet;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let {
|
|
66
|
+
field,
|
|
67
|
+
name,
|
|
68
|
+
rows: seedRows,
|
|
69
|
+
targets,
|
|
70
|
+
markFieldsDirty,
|
|
71
|
+
mediaLibrary,
|
|
72
|
+
conceptId,
|
|
73
|
+
id,
|
|
74
|
+
heroFieldRefs,
|
|
75
|
+
onuploaded,
|
|
76
|
+
onheroneedsalt,
|
|
77
|
+
icons,
|
|
78
|
+
}: Props = $props();
|
|
79
|
+
|
|
80
|
+
/** One row of editable state: a stable id for keyed identity, and the row's current value. */
|
|
81
|
+
interface Row {
|
|
82
|
+
id: number;
|
|
83
|
+
value: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The id of the next appended row. Seeded past the initial rows so a freshly added row never
|
|
87
|
+
// collides with a seeded one; a plain counter (not randomUUID) so SSR and the client agree.
|
|
88
|
+
let nextId = untrack(() => seedRows.length);
|
|
89
|
+
|
|
90
|
+
// The rows, seeded once from the prop and owned thereafter (untrack marks the read a deliberate
|
|
91
|
+
// one-time seed). No re-seed effect: EditPage's {#key entryKey} wrapper remounts on entry change.
|
|
92
|
+
let rows = $state<Row[]>(untrack(() => seedRows.map((value, i) => ({ id: i, value }))));
|
|
93
|
+
|
|
94
|
+
// Which rows are expanded for editing, keyed by row id; a collapsed row shows its summary only.
|
|
95
|
+
let expanded = $state<Record<number, boolean>>({});
|
|
96
|
+
|
|
97
|
+
// The polite announcement, mounted empty and filled on a structural mutation.
|
|
98
|
+
let announcement = $state('');
|
|
99
|
+
|
|
100
|
+
// The Add button, the last link in the remove focus chain (when no row remains to focus).
|
|
101
|
+
let addButton = $state<HTMLButtonElement | null>(null);
|
|
102
|
+
|
|
103
|
+
// This instance's outer fieldset. Every focus query scopes to it, so two RepeatableField lists on
|
|
104
|
+
// one page (the showcase posts concept renders both an array(object) and an array(image)) never
|
|
105
|
+
// move focus into the other list. A document-wide query would index across both row sets.
|
|
106
|
+
let root = $state<HTMLFieldSetElement | null>(null);
|
|
107
|
+
|
|
108
|
+
const isObjectItem = $derived(field.item.type === 'object');
|
|
109
|
+
const rowLabel = $derived(field.label ?? field.name);
|
|
110
|
+
|
|
111
|
+
// The live itemLabel text per row id, tracked so a collapsed row's summary follows the author's
|
|
112
|
+
// edits. The row inputs stay uncontrolled (the keyed envelope owns the seed; writing row.value on
|
|
113
|
+
// every keystroke risks edit loss), so this mirrors only the summary-label field, read on input.
|
|
114
|
+
let summaries = $state<Record<number, string>>({});
|
|
115
|
+
|
|
116
|
+
// The form name of a row's itemLabel field: the object sub-field path, or the leaf row path itself.
|
|
117
|
+
function summaryNameFor(index: number): string {
|
|
118
|
+
return isObjectItem && field.itemLabel != null
|
|
119
|
+
? `${name}.${index}.${field.itemLabel}`
|
|
120
|
+
: `${name}.${index}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Mirror a row's itemLabel value into the live summary map when its input fires. A non-summary
|
|
124
|
+
// input in the row is ignored, so the collapsed label tracks only the label field.
|
|
125
|
+
function onRowInput(row: Row, index: number, event: Event) {
|
|
126
|
+
const target = event.target as HTMLInputElement;
|
|
127
|
+
if (target.name === summaryNameFor(index)) {
|
|
128
|
+
summaries = { ...summaries, [row.id]: target.value };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The collapsed summary for a row: the live itemLabel text when the author has edited it, else its
|
|
133
|
+
// seeded itemLabel value (object row) or the leaf value, falling back to a positional placeholder.
|
|
134
|
+
function summaryFor(value: unknown, index: number, rowId: number): string {
|
|
135
|
+
if (rowId in summaries) {
|
|
136
|
+
const live = summaries[rowId].trim();
|
|
137
|
+
if (live !== '') return live;
|
|
138
|
+
}
|
|
139
|
+
let text = '';
|
|
140
|
+
if (isObjectItem && field.itemLabel != null && value !== null && typeof value === 'object') {
|
|
141
|
+
text = String((value as Record<string, unknown>)[field.itemLabel] ?? '').trim();
|
|
142
|
+
} else if (!isObjectItem && value != null && typeof value !== 'object') {
|
|
143
|
+
text = String(value).trim();
|
|
144
|
+
}
|
|
145
|
+
return text !== '' ? text : `${rowLabel} ${index + 1}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// An empty value for a freshly added row: an empty object for an object item, an empty string for
|
|
149
|
+
// a leaf. The decoder prunes an all-default row, so an untouched added row never reaches the form.
|
|
150
|
+
function emptyValue(): unknown {
|
|
151
|
+
return isObjectItem ? {} : '';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toggle(rowId: number) {
|
|
155
|
+
expanded = { ...expanded, [rowId]: !expanded[rowId] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function add() {
|
|
159
|
+
const row: Row = { id: nextId++, value: emptyValue() };
|
|
160
|
+
rows = [...rows, row];
|
|
161
|
+
expanded = { ...expanded, [row.id]: true };
|
|
162
|
+
markFieldsDirty();
|
|
163
|
+
announcement = 'Row added';
|
|
164
|
+
await tick();
|
|
165
|
+
// Land focus on the first operable control in the new row's EDIT BODY (not the row header's
|
|
166
|
+
// toggle/move/remove chrome). The selector spans an array(image) row too, whose MediaHeroField
|
|
167
|
+
// empty state is a <button> dropzone with only hidden inputs.
|
|
168
|
+
const firstInput = root?.querySelector<HTMLElement>(
|
|
169
|
+
`[data-cairn-row="${row.id}"] [data-cairn-row-body] :is(input:not([type=hidden]),textarea,select,button)`,
|
|
170
|
+
);
|
|
171
|
+
firstInput?.focus();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function remove(index: number) {
|
|
175
|
+
rows = rows.filter((_, i) => i !== index);
|
|
176
|
+
markFieldsDirty();
|
|
177
|
+
announcement = 'Row removed';
|
|
178
|
+
await tick();
|
|
179
|
+
// Focus chain: the next row's remove control, else the previous row's, else the Add button.
|
|
180
|
+
const removeButtons = root?.querySelectorAll<HTMLElement>('[data-cairn-row-remove]') ?? [];
|
|
181
|
+
if (removeButtons[index]) removeButtons[index].focus();
|
|
182
|
+
else if (removeButtons[index - 1]) removeButtons[index - 1].focus();
|
|
183
|
+
else addButton?.focus();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function move(index: number, dir: 1 | -1) {
|
|
187
|
+
const target = index + dir;
|
|
188
|
+
if (target < 0 || target >= rows.length) return;
|
|
189
|
+
rows = sortItems(rows, index, target);
|
|
190
|
+
markFieldsDirty();
|
|
191
|
+
await tick();
|
|
192
|
+
// Keep keyboard focus on the moved row after a reorder. The arrow that fired the move can become
|
|
193
|
+
// disabled at the first or last boundary, so focus the opposite-direction arrow, falling back to
|
|
194
|
+
// the row toggle. Both queries scope to this instance's fieldset.
|
|
195
|
+
const movedRow = root?.querySelectorAll<HTMLElement>('[data-cairn-row]')[target];
|
|
196
|
+
const opposite = dir === 1 ? '[data-cairn-row-up]' : '[data-cairn-row-down]';
|
|
197
|
+
const focusTarget =
|
|
198
|
+
movedRow?.querySelector<HTMLElement>(`${opposite}:not([disabled])`) ??
|
|
199
|
+
movedRow?.querySelector<HTMLElement>('[data-cairn-row-toggle]');
|
|
200
|
+
focusTarget?.focus();
|
|
201
|
+
}
|
|
202
|
+
</script>
|
|
203
|
+
|
|
204
|
+
<fieldset bind:this={root} class="m-0 flex min-w-0 flex-col gap-2 border-0 p-0">
|
|
205
|
+
<legend class="text-sm font-medium">{rowLabel}</legend>
|
|
206
|
+
|
|
207
|
+
{#if rows.length}
|
|
208
|
+
<ul class="flex flex-col gap-2">
|
|
209
|
+
{#each rows as row, i (row.id)}
|
|
210
|
+
{@const rowSummary = summaryFor(row.value, i, row.id)}
|
|
211
|
+
<li
|
|
212
|
+
class="rounded-[var(--radius-field)] border border-[var(--color-base-300)]"
|
|
213
|
+
data-cairn-row={row.id}
|
|
214
|
+
oninput={(e) => onRowInput(row, i, e)}
|
|
215
|
+
>
|
|
216
|
+
<div class="flex items-center gap-1 p-1">
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
class="btn btn-ghost btn-sm flex-1 justify-start gap-2 font-normal"
|
|
220
|
+
data-cairn-row-toggle
|
|
221
|
+
aria-expanded={expanded[row.id] ? 'true' : 'false'}
|
|
222
|
+
onclick={() => toggle(row.id)}
|
|
223
|
+
>
|
|
224
|
+
{#if expanded[row.id]}
|
|
225
|
+
<ChevronDownIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
226
|
+
{:else}
|
|
227
|
+
<ChevronRightIcon class="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
228
|
+
{/if}
|
|
229
|
+
<span class="truncate">{rowSummary}</span>
|
|
230
|
+
</button>
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
234
|
+
data-cairn-row-up
|
|
235
|
+
aria-label={`Move ${rowSummary} up`}
|
|
236
|
+
disabled={i === 0}
|
|
237
|
+
onclick={() => move(i, -1)}
|
|
238
|
+
>
|
|
239
|
+
<ArrowUpIcon class="h-4 w-4" aria-hidden="true" />
|
|
240
|
+
</button>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
244
|
+
data-cairn-row-down
|
|
245
|
+
aria-label={`Move ${rowSummary} down`}
|
|
246
|
+
disabled={i === rows.length - 1}
|
|
247
|
+
onclick={() => move(i, 1)}
|
|
248
|
+
>
|
|
249
|
+
<ArrowDownIcon class="h-4 w-4" aria-hidden="true" />
|
|
250
|
+
</button>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
254
|
+
data-cairn-row-remove
|
|
255
|
+
aria-label={`Remove ${rowSummary}`}
|
|
256
|
+
onclick={() => remove(i)}
|
|
257
|
+
>
|
|
258
|
+
<Trash2Icon class="h-4 w-4" aria-hidden="true" />
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
{#if expanded[row.id]}
|
|
262
|
+
<div data-cairn-row-body class="flex flex-col gap-3 border-t border-[var(--color-base-300)] p-3">
|
|
263
|
+
{#if field.item.type === 'object'}
|
|
264
|
+
<ObjectGroupField
|
|
265
|
+
field={{ ...(field.item as ObjectField), name: field.name }}
|
|
266
|
+
name={`${name}.${i}`}
|
|
267
|
+
frontmatter={(row.value !== null && typeof row.value === 'object' ? row.value : {}) as Record<string, unknown>}
|
|
268
|
+
{targets}
|
|
269
|
+
{markFieldsDirty}
|
|
270
|
+
{mediaLibrary}
|
|
271
|
+
{conceptId}
|
|
272
|
+
{id}
|
|
273
|
+
{heroFieldRefs}
|
|
274
|
+
{onuploaded}
|
|
275
|
+
{onheroneedsalt}
|
|
276
|
+
{icons}
|
|
277
|
+
/>
|
|
278
|
+
{:else}
|
|
279
|
+
<FieldInput
|
|
280
|
+
field={{ ...field.item, name: '_value' }}
|
|
281
|
+
name={`${name}.${i}`}
|
|
282
|
+
frontmatter={{ _value: row.value }}
|
|
283
|
+
{targets}
|
|
284
|
+
{markFieldsDirty}
|
|
285
|
+
{mediaLibrary}
|
|
286
|
+
{conceptId}
|
|
287
|
+
{id}
|
|
288
|
+
{heroFieldRefs}
|
|
289
|
+
{onuploaded}
|
|
290
|
+
{onheroneedsalt}
|
|
291
|
+
{icons}
|
|
292
|
+
/>
|
|
293
|
+
{/if}
|
|
294
|
+
</div>
|
|
295
|
+
{/if}
|
|
296
|
+
</li>
|
|
297
|
+
{/each}
|
|
298
|
+
</ul>
|
|
299
|
+
{/if}
|
|
300
|
+
|
|
301
|
+
<div>
|
|
302
|
+
<button type="button" class="btn btn-sm btn-ghost gap-1" bind:this={addButton} onclick={add}>
|
|
303
|
+
<PlusIcon class="h-4 w-4" aria-hidden="true" />
|
|
304
|
+
Add {rowLabel}
|
|
305
|
+
</button>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<!-- Always mounted so add/remove announce consistently; a {#if}-gated region announces unevenly. -->
|
|
309
|
+
<div role="status" aria-live="polite" class="sr-only">{announcement}</div>
|
|
310
|
+
</fieldset>
|
|
@@ -64,9 +64,13 @@ export function buildPreviewDoc(html: string, preview: ResolvedPreview | null):
|
|
|
64
64
|
// parent's base URL, so a clicked fragment or root link could render the admin login inside
|
|
65
65
|
// the frame. Targeting every link at a new tab turns each click into a popup, and the sandbox
|
|
66
66
|
// (which grants no allow-popups) blocks it, so a proofing click goes nowhere.
|
|
67
|
+
// The marker on the root lets a site scope an entrance animation (driven off [data-rise]) away
|
|
68
|
+
// from the preview, which shows the resting state of content and runs the same pipeline; without
|
|
69
|
+
// it, content would re-animate on every debounced render. cairn provides the hook; the site owns
|
|
70
|
+
// its animation and decides what to suppress under [data-cairn-preview].
|
|
67
71
|
return [
|
|
68
72
|
'<!doctype html>',
|
|
69
|
-
'<html>',
|
|
73
|
+
'<html data-cairn-preview>',
|
|
70
74
|
'<head>',
|
|
71
75
|
'<meta charset="utf-8">',
|
|
72
76
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
@@ -62,7 +62,7 @@ const DIVERGENCE_FRACTION = 0.5;
|
|
|
62
62
|
// text rather than going through extractMediaRefs for two reasons. First, a true MULTISET is the
|
|
63
63
|
// invariant a backstop wants: extractMediaRefs dedups by hash, so a doubled token collapsing to one
|
|
64
64
|
// would read as equal, and the validator must catch a dropped duplicate. Second, the raw scan covers
|
|
65
|
-
// the whole text including frontmatter without threading the concept's
|
|
65
|
+
// the whole text including frontmatter without threading the concept's NamedField[] to the call
|
|
66
66
|
// site, which the validator otherwise has no reason to know. A token mangled inside a code fence is
|
|
67
67
|
// caught here too, redundantly with the code check, which is the right posture for a backstop.
|
|
68
68
|
const MEDIA_TOKEN = /media:[A-Za-z0-9.-]+/g;
|
|
@@ -4,7 +4,28 @@
|
|
|
4
4
|
// full-auto typed reads in createSiteIndexes, while still checking the adapter against the contract.
|
|
5
5
|
import type { CairnAdapter } from './types.js';
|
|
6
6
|
|
|
7
|
+
// Fail closed on an inconsistent island registry: a hydrate component with no live component, or a
|
|
8
|
+
// registered island with no hydrate component. Either is a wiring mistake the site author should see at
|
|
9
|
+
// build time, not a silent forever-fallback. Read-only over the rendering group; imports no runtime.
|
|
10
|
+
function assertIslandsConsistent(rendering: CairnAdapter['rendering']): void {
|
|
11
|
+
const islands = rendering.islands ?? {};
|
|
12
|
+
const hydrated = new Set(
|
|
13
|
+
(rendering.components?.defs ?? []).filter((d) => d.hydrate).map((d) => d.name),
|
|
14
|
+
);
|
|
15
|
+
for (const name of hydrated) {
|
|
16
|
+
if (!(name in islands)) {
|
|
17
|
+
throw new Error(`cairn: component '${name}' declares hydrate but rendering.islands has no entry for it.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
for (const name of Object.keys(islands)) {
|
|
21
|
+
if (!hydrated.has(name)) {
|
|
22
|
+
throw new Error(`cairn: rendering.islands has '${name}' but no component declares hydrate for it.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
/** Declare a site's adapter while preserving each concept's concrete schema type for typed reads. */
|
|
8
28
|
export function defineAdapter<const A extends CairnAdapter>(adapter: A): A {
|
|
29
|
+
assertIslandsConsistent(adapter.rendering);
|
|
9
30
|
return adapter;
|
|
10
31
|
}
|
|
@@ -12,10 +12,8 @@
|
|
|
12
12
|
// notice and never blocks the editor or the publish. The scope splits by call site: the main arm at
|
|
13
13
|
// edit-load (synchronous, no extra GitHub read per open) and the full cross-branch check at publish.
|
|
14
14
|
import type { ConceptDescriptor } from './types.js';
|
|
15
|
-
import type {
|
|
15
|
+
import type { Backend } from '../github/backend.js';
|
|
16
16
|
import type { Manifest } from './manifest.js';
|
|
17
|
-
import { listBranches } from '../github/branches.js';
|
|
18
|
-
import { readRaw } from '../github/repo.js';
|
|
19
17
|
import { PENDING_PREFIX, parsePendingBranch } from './pending.js';
|
|
20
18
|
import { findConcept } from './concepts.js';
|
|
21
19
|
import { isValidId, filenameFromId } from './ids.js';
|
|
@@ -90,8 +88,7 @@ export function mainAddressIndex(manifest: Manifest): AddressIndex {
|
|
|
90
88
|
* publish. The branches are read in one Promise.all, the way buildUsageIndex reads them.
|
|
91
89
|
*/
|
|
92
90
|
export async function buildAddressIndex(
|
|
93
|
-
|
|
94
|
-
token: string,
|
|
91
|
+
backend: Backend,
|
|
95
92
|
concepts: ConceptDescriptor[],
|
|
96
93
|
manifest: Manifest,
|
|
97
94
|
): Promise<AddressIndex> {
|
|
@@ -101,7 +98,7 @@ export async function buildAddressIndex(
|
|
|
101
98
|
|
|
102
99
|
// The branch arm: read each open cairn/* branch's one edited file and resolve its permalink. The
|
|
103
100
|
// path is derivable from the branch name, so no tree-listing is needed.
|
|
104
|
-
const names = await listBranches(
|
|
101
|
+
const names = await backend.listBranches(PENDING_PREFIX);
|
|
105
102
|
const perBranch = await Promise.all(
|
|
106
103
|
names.map(async (name): Promise<{ permalink: string; entry: AddressEntry } | null> => {
|
|
107
104
|
// Resolve the branch name with the branch tooling's guard: a malformed name, an id that fails
|
|
@@ -113,7 +110,7 @@ export async function buildAddressIndex(
|
|
|
113
110
|
|
|
114
111
|
const path = `${concept.dir}/${filenameFromId(ref.id)}`;
|
|
115
112
|
try {
|
|
116
|
-
const raw = await
|
|
113
|
+
const raw = await backend.readFile(path, name);
|
|
117
114
|
if (raw === null) return null; // The file is absent on the branch: nothing to resolve.
|
|
118
115
|
const { frontmatter } = parseMarkdown(raw);
|
|
119
116
|
const fmTitle = frontmatter.title;
|
|
@@ -8,10 +8,18 @@ import { resolveConcepts } from './concepts.js';
|
|
|
8
8
|
import { normalizeAssets } from '../media/config.js';
|
|
9
9
|
import { dictionaryFileForDialect, type SiteConfig } from '../nav/site-config.js';
|
|
10
10
|
|
|
11
|
+
// The internal artifact paths the adapter does not carry. They share the `.cairn/` content root the
|
|
12
|
+
// manifests use, so `composeRuntime` defaults them by convention rather than reading them off config.
|
|
13
|
+
// The personal dictionary sits beside the manifests, so the spec's `content/.cairn/dictionary.txt`
|
|
14
|
+
// resolves the same configurable way the manifest paths do.
|
|
15
|
+
const CONTENT_MANIFEST_PATH = 'src/content/.cairn/index.json';
|
|
16
|
+
const MEDIA_MANIFEST_PATH = 'src/content/.cairn/media.json';
|
|
17
|
+
const DICTIONARY_PATH = 'src/content/.cairn/dictionary.txt';
|
|
18
|
+
|
|
11
19
|
/**
|
|
12
|
-
* The input to {@link composeRuntime}. `siteConfig` is required
|
|
13
|
-
*
|
|
14
|
-
* adapter's concepts.
|
|
20
|
+
* The input to {@link composeRuntime}. `siteConfig` is required: it is the canonical home for the
|
|
21
|
+
* site name, the spellcheck dialect, and the tidy block, so they can never be silently dropped.
|
|
22
|
+
* `extensions` fold in after the adapter's concepts.
|
|
15
23
|
*/
|
|
16
24
|
export interface ComposeInput {
|
|
17
25
|
adapter: CairnAdapter;
|
|
@@ -20,13 +28,14 @@ export interface ComposeInput {
|
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
|
-
* Fold an adapter and any extensions into the composed runtime (seam 2).
|
|
24
|
-
*
|
|
25
|
-
*
|
|
31
|
+
* Fold an adapter and any extensions into the composed runtime (seam 2). This is the one place the
|
|
32
|
+
* grouped adapter maps onto the flat runtime, and the one place the internal manifest and dictionary
|
|
33
|
+
* paths default by convention. Each concept declares its own routing and URL policy, so the runtime
|
|
34
|
+
* and delivery permalinks cannot diverge. Extension concepts merge after the adapter's. The media slot
|
|
26
35
|
* (seam 4) passes through untouched.
|
|
27
36
|
*/
|
|
28
37
|
export function composeRuntime({ adapter, siteConfig, extensions = [] }: ComposeInput): CairnRuntime {
|
|
29
|
-
if (!siteConfig) throw new Error('composeRuntime needs a site config
|
|
38
|
+
if (!siteConfig) throw new Error('composeRuntime needs a site config for the site name and editor settings');
|
|
30
39
|
const content: Record<string, ConceptConfig | undefined> = { ...adapter.content };
|
|
31
40
|
const adminPanels: AdminPanel[] = [];
|
|
32
41
|
const fieldTypes: FieldTypeDef[] = [];
|
|
@@ -38,23 +47,21 @@ export function composeRuntime({ adapter, siteConfig, extensions = [] }: Compose
|
|
|
38
47
|
if (extension.fieldTypes) fieldTypes.push(...extension.fieldTypes);
|
|
39
48
|
}
|
|
40
49
|
return {
|
|
41
|
-
siteName:
|
|
42
|
-
concepts: resolveConcepts(content
|
|
50
|
+
siteName: siteConfig.siteName,
|
|
51
|
+
concepts: resolveConcepts(content),
|
|
43
52
|
backend: adapter.backend,
|
|
44
|
-
sender: adapter.
|
|
45
|
-
supportContact: adapter.supportContact,
|
|
46
|
-
render: adapter.render,
|
|
47
|
-
manifestPath:
|
|
48
|
-
registry: adapter.
|
|
49
|
-
icons: adapter.icons,
|
|
50
|
-
navMenu: adapter.
|
|
51
|
-
preview: adapter.preview,
|
|
52
|
-
assets: adapter.
|
|
53
|
-
resolvedAssets: normalizeAssets(adapter.
|
|
54
|
-
mediaManifestPath:
|
|
55
|
-
|
|
56
|
-
// spec's `content/.cairn/dictionary.txt` resolves the same configurable way the manifest paths do.
|
|
57
|
-
dictionaryPath: adapter.dictionaryPath ?? 'src/content/.cairn/dictionary.txt',
|
|
53
|
+
sender: adapter.email,
|
|
54
|
+
supportContact: adapter.editor?.supportContact,
|
|
55
|
+
render: adapter.rendering.render,
|
|
56
|
+
manifestPath: CONTENT_MANIFEST_PATH,
|
|
57
|
+
registry: adapter.rendering.components,
|
|
58
|
+
icons: adapter.rendering.icons,
|
|
59
|
+
navMenu: adapter.editor?.nav,
|
|
60
|
+
preview: adapter.editor?.preview,
|
|
61
|
+
assets: adapter.media,
|
|
62
|
+
resolvedAssets: normalizeAssets(adapter.media),
|
|
63
|
+
mediaManifestPath: MEDIA_MANIFEST_PATH,
|
|
64
|
+
dictionaryPath: DICTIONARY_PATH,
|
|
58
65
|
// The spellcheck dictionary is resolved once here from the site config's dialect (default US),
|
|
59
66
|
// so the runtime and the editor never re-derive it. The site config is the one home for the
|
|
60
67
|
// dialect; the editor resolves this filename to a real asset URL on the main thread.
|