@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
|
@@ -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.
|
|
@@ -1,23 +1,41 @@
|
|
|
1
|
-
// cairn-cms: concept normalization (seam 1). The adapter declares concepts as
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
import type {
|
|
7
|
-
import { urlPolicyFrom, type SiteConfig } from '../nav/site-config.js';
|
|
1
|
+
// cairn-cms: concept normalization (seam 1). The adapter declares concepts as an open `content`
|
|
2
|
+
// record; this turns each declared key into a uniform descriptor (id, label, directory, declared
|
|
3
|
+
// routing, fields, validator) the admin reads. A new concept attaches by adding one key under
|
|
4
|
+
// `content` and declaring its own routing and URL policy, with no reshape here.
|
|
5
|
+
import type { ConceptConfig, ConceptDescriptor, ConceptUrlPolicy, NamedField, RoutingRule } from './types.js';
|
|
6
|
+
import type { Fieldset } from './fieldset.js';
|
|
8
7
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
/** Re-attach each fieldset record key to its descriptor as `name`, the normalized `NamedField[]`. */
|
|
9
|
+
function namedFields(schema: Fieldset): NamedField[] {
|
|
10
|
+
return Object.entries(schema.fields).map(([name, descriptor]) => ({ name, ...descriptor }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The named routing shorthands, each expanding to a concrete rule. */
|
|
14
|
+
const ROUTING_SHORTHANDS: Readonly<Record<'feed' | 'page' | 'embedded', RoutingRule>> = {
|
|
15
|
+
feed: { routable: true, dated: true, inFeeds: true },
|
|
16
|
+
page: { routable: true, dated: false, inFeeds: false },
|
|
17
|
+
embedded: { routable: false, dated: false, inFeeds: false },
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
20
|
+
/** Expand a concept's routing shorthand to a concrete rule. The single resolution point: omitted is `page`. */
|
|
21
|
+
export function resolveRouting(routing: ConceptConfig['routing']): RoutingRule {
|
|
22
|
+
if (routing === undefined) return ROUTING_SHORTHANDS.page;
|
|
23
|
+
return typeof routing === 'string' ? ROUTING_SHORTHANDS[routing] : routing;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Declare a concept while preserving its fieldset type for typed reads, and validate its URL policy at
|
|
28
|
+
* declaration so a bad permalink or datePrefix fails at module load rather than at a defaulted render.
|
|
29
|
+
* Mirrors {@link defineAdapter}; the validation is the build-independent net for a concept with no entries.
|
|
30
|
+
*/
|
|
31
|
+
export function defineConcept<const C extends ConceptConfig>(concept: C): C {
|
|
32
|
+
validateUrlPolicy(
|
|
33
|
+
concept.label ?? concept.dir,
|
|
34
|
+
{ permalink: concept.permalink, datePrefix: concept.datePrefix },
|
|
35
|
+
resolveRouting(concept.routing).dated,
|
|
36
|
+
);
|
|
37
|
+
return concept;
|
|
38
|
+
}
|
|
21
39
|
|
|
22
40
|
/** Title-case a concept id for the default sidebar label, e.g. "posts" to "Posts". */
|
|
23
41
|
function defaultLabel(id: string): string {
|
|
@@ -41,7 +59,7 @@ const DATE_PREFIXES = new Set<string>(['year', 'month', 'day']);
|
|
|
41
59
|
* here rather than emitting a wrong or defaulted URL at render. The permalink must be root-relative and
|
|
42
60
|
* use only known tokens, a date token requires a dated concept, and the datePrefix must be in range.
|
|
43
61
|
*/
|
|
44
|
-
function validateUrlPolicy(id: string, policy: ConceptUrlPolicy, dated: boolean): void {
|
|
62
|
+
export function validateUrlPolicy(id: string, policy: ConceptUrlPolicy, dated: boolean): void {
|
|
45
63
|
if (policy.permalink !== undefined) {
|
|
46
64
|
const pattern = policy.permalink;
|
|
47
65
|
if (!pattern.startsWith('/')) {
|
|
@@ -67,38 +85,48 @@ function validateUrlPolicy(id: string, policy: ConceptUrlPolicy, dated: boolean)
|
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
/**
|
|
70
|
-
* Normalize an adapter's declared concepts into uniform descriptors (seam 1).
|
|
71
|
-
* (
|
|
72
|
-
*
|
|
73
|
-
* otherwise; `datePrefix` defaults to `day`).
|
|
74
|
-
* a new concept attaches additively; production passes the default `CONCEPT_ROUTING`.
|
|
88
|
+
* Normalize an adapter's declared concepts into uniform descriptors (seam 1). Each concept declares its
|
|
89
|
+
* own routing (a shorthand or an explicit rule, resolved by `resolveRouting`) and URL policy
|
|
90
|
+
* (`permalink`, `datePrefix`) on the config; both default when omitted (`/:slug` for Pages, `/<id>/:slug`
|
|
91
|
+
* otherwise; `datePrefix` defaults to `day`). A new concept attaches by adding one key under `content`.
|
|
75
92
|
*/
|
|
76
93
|
export function normalizeConcepts(
|
|
77
94
|
content: Record<string, ConceptConfig | undefined>,
|
|
78
|
-
urlPolicy: Record<string, ConceptUrlPolicy | undefined> = {},
|
|
79
|
-
routing: Readonly<Record<string, RoutingRule>> = CONCEPT_ROUTING,
|
|
80
95
|
): ConceptDescriptor[] {
|
|
81
96
|
const descriptors: ConceptDescriptor[] = [];
|
|
82
97
|
const declaredConcepts = new Set(
|
|
83
98
|
Object.keys(content).filter((key) => content[key] !== undefined),
|
|
84
99
|
);
|
|
85
|
-
for (const key of Object.keys(urlPolicy)) {
|
|
86
|
-
if (!declaredConcepts.has(key)) {
|
|
87
|
-
throw new Error(`cairn: URL policy names concept "${key}", which is not declared under content`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
100
|
for (const [id, config] of Object.entries(content)) {
|
|
91
101
|
if (!config) continue;
|
|
102
|
+
const fs = config.fields;
|
|
92
103
|
const summaryFields = config.summaryFields ?? [];
|
|
93
|
-
const declared = new Set(
|
|
104
|
+
const declared = new Set(Object.keys(fs.fields));
|
|
94
105
|
const undeclared = summaryFields.find((key) => !declared.has(key));
|
|
95
106
|
if (undeclared !== undefined) {
|
|
96
107
|
throw new Error(
|
|
97
108
|
`cairn: concept "${id}" summaryFields key "${undeclared}" is not a declared field`,
|
|
98
109
|
);
|
|
99
110
|
}
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
// A reference (or array of reference) field names the concept it targets. Validate that concept at
|
|
112
|
+
// declaration, so a typo fails loudly here rather than at the build's verifyReferences gate (or, in
|
|
113
|
+
// the editor picker, as a silently empty target list). The check is the field descriptor's concept
|
|
114
|
+
// against the declared content keys.
|
|
115
|
+
for (const [name, descriptor] of Object.entries(fs.fields)) {
|
|
116
|
+
const targetConcept =
|
|
117
|
+
descriptor.type === 'reference'
|
|
118
|
+
? descriptor.concept
|
|
119
|
+
: descriptor.type === 'array' && descriptor.item.type === 'reference'
|
|
120
|
+
? descriptor.item.concept
|
|
121
|
+
: undefined;
|
|
122
|
+
if (targetConcept !== undefined && !declaredConcepts.has(targetConcept)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`cairn: concept "${id}" reference field "${name}" names concept "${targetConcept}", which is not declared under content`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const conceptRouting = resolveRouting(config.routing);
|
|
129
|
+
const policy: ConceptUrlPolicy = { permalink: config.permalink, datePrefix: config.datePrefix };
|
|
102
130
|
validateUrlPolicy(id, policy, conceptRouting.dated);
|
|
103
131
|
const label = config.label ?? defaultLabel(id);
|
|
104
132
|
descriptors.push({
|
|
@@ -109,24 +137,24 @@ export function normalizeConcepts(
|
|
|
109
137
|
routing: conceptRouting,
|
|
110
138
|
permalink: policy.permalink ?? defaultPermalink(id),
|
|
111
139
|
datePrefix: policy.datePrefix ?? 'day',
|
|
112
|
-
fields:
|
|
140
|
+
fields: namedFields(fs),
|
|
141
|
+
schema: fs,
|
|
113
142
|
summaryFields,
|
|
114
|
-
validate:
|
|
143
|
+
validate: fs.validate,
|
|
115
144
|
});
|
|
116
145
|
}
|
|
117
146
|
return descriptors;
|
|
118
147
|
}
|
|
119
148
|
|
|
120
149
|
/**
|
|
121
|
-
* Resolve a site's concept descriptors from its content map
|
|
122
|
-
*
|
|
123
|
-
*
|
|
150
|
+
* Resolve a site's concept descriptors from its content map. The admin runtime (composeRuntime) and the
|
|
151
|
+
* delivery layer (siteDescriptors) both call this, so the per-concept routing and URL policy are derived
|
|
152
|
+
* once from the concept declarations and the runtime and delivery permalinks cannot diverge.
|
|
124
153
|
*/
|
|
125
154
|
export function resolveConcepts(
|
|
126
155
|
content: Record<string, ConceptConfig | undefined>,
|
|
127
|
-
siteConfig: SiteConfig,
|
|
128
156
|
): ConceptDescriptor[] {
|
|
129
|
-
return normalizeConcepts(content
|
|
157
|
+
return normalizeConcepts(content);
|
|
130
158
|
}
|
|
131
159
|
|
|
132
160
|
/** Look up a normalized concept by id, or undefined when the site does not enable it. */
|