@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
|
@@ -3,11 +3,84 @@
|
|
|
3
3
|
// on-disk write/read pair. Kept as one seam so a site owns its serialization contract
|
|
4
4
|
// (quoting, key order) without the save endpoint reaching for gray-matter directly.
|
|
5
5
|
import matter from 'gray-matter';
|
|
6
|
-
import type {
|
|
6
|
+
import type { ImageValue, NamedField } from './types.js';
|
|
7
|
+
import type { FieldDescriptor } from './fields.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* True when a multiselect field is a closed checkbox group: it declares an options vocabulary and is
|
|
11
|
+
* not author-extendable. The save decoder and the editor render arm both call this, so the
|
|
12
|
+
* closed-versus-open multiselect decision can never drift between decode and display.
|
|
13
|
+
*/
|
|
14
|
+
export function isClosedMultiselect(field: {
|
|
15
|
+
type: string;
|
|
16
|
+
options?: readonly string[];
|
|
17
|
+
creatable?: boolean;
|
|
18
|
+
}): boolean {
|
|
19
|
+
return field.type === 'multiselect' && !!field.options && !field.creatable;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Decode one field addressed by `name`, for NESTED use (object leaves, array rows). Returns undefined
|
|
23
|
+
// when empty so the caller omits the key; this nested contract differs from the top-level arms, which
|
|
24
|
+
// preserve '' / [] for back-compat. Recurses one level for an object item.
|
|
25
|
+
function decodeField(name: string, field: FieldDescriptor, form: FormData): unknown {
|
|
26
|
+
switch (field.type) {
|
|
27
|
+
case 'boolean':
|
|
28
|
+
return form.get(name) === 'on' ? true : undefined;
|
|
29
|
+
case 'multiselect': {
|
|
30
|
+
const list = isClosedMultiselect(field)
|
|
31
|
+
? form.getAll(name).map(String)
|
|
32
|
+
: [...new Set(String(form.get(name) ?? '').split(',').map((t) => t.trim()).filter(Boolean))];
|
|
33
|
+
return list.length > 0 ? list : undefined;
|
|
34
|
+
}
|
|
35
|
+
case 'image': {
|
|
36
|
+
const src = String(form.get(`${name}.src`) ?? '').trim();
|
|
37
|
+
if (src === '') return undefined;
|
|
38
|
+
const value: ImageValue = { src, alt: String(form.get(`${name}.alt`) ?? '') };
|
|
39
|
+
const caption = String(form.get(`${name}.caption`) ?? '').trim();
|
|
40
|
+
if (caption !== '') value.caption = caption;
|
|
41
|
+
if (String(form.get(`${name}.decorative`) ?? '') === 'true') value.decorative = true;
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
case 'object': {
|
|
45
|
+
const obj: Record<string, unknown> = {};
|
|
46
|
+
for (const [leafKey, leaf] of Object.entries(field.fields)) {
|
|
47
|
+
const v = decodeField(`${name}.${leafKey}`, leaf, form);
|
|
48
|
+
if (v !== undefined) obj[leafKey] = v;
|
|
49
|
+
}
|
|
50
|
+
return Object.keys(obj).length > 0 ? obj : undefined;
|
|
51
|
+
}
|
|
52
|
+
default: {
|
|
53
|
+
// text, textarea, number-as-string, url, email, date, datetime: a trimmed non-empty string.
|
|
54
|
+
const s = String(form.get(name) ?? '').trim();
|
|
55
|
+
return s === '' ? undefined : s;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Enumerate array rows by any present name.<i>.* key, decode each item, and drop a row that decodes to
|
|
61
|
+
// empty (minimal-frontmatter). A row with any non-default content emits at least one key (a text leaf
|
|
62
|
+
// submits even when empty; a checked boolean submits its key), so a present-but-meaningful row is always
|
|
63
|
+
// seen; a fully all-default row carries no data and is correctly pruned. Output order follows ascending
|
|
64
|
+
// index, which the editor's keyed rows keep in sync.
|
|
65
|
+
function decodeRows(name: string, item: FieldDescriptor, form: FormData): unknown[] {
|
|
66
|
+
const indices = new Set<number>();
|
|
67
|
+
const prefix = `${name}.`;
|
|
68
|
+
for (const k of form.keys()) {
|
|
69
|
+
if (!k.startsWith(prefix)) continue;
|
|
70
|
+
const n = Number(k.slice(prefix.length).split('.')[0]);
|
|
71
|
+
if (Number.isInteger(n)) indices.add(n);
|
|
72
|
+
}
|
|
73
|
+
const rows: unknown[] = [];
|
|
74
|
+
for (const i of [...indices].sort((a, b) => a - b)) {
|
|
75
|
+
const v = decodeField(`${name}.${i}`, item, form);
|
|
76
|
+
if (v !== undefined) rows.push(v);
|
|
77
|
+
}
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
7
80
|
|
|
8
81
|
/** Decode submitted form data into raw frontmatter, one rule per field type. */
|
|
9
82
|
export function frontmatterFromForm(
|
|
10
|
-
fields:
|
|
83
|
+
fields: NamedField[],
|
|
11
84
|
form: FormData,
|
|
12
85
|
): Record<string, unknown> {
|
|
13
86
|
const data: Record<string, unknown> = {};
|
|
@@ -16,19 +89,21 @@ export function frontmatterFromForm(
|
|
|
16
89
|
case 'boolean':
|
|
17
90
|
data[field.name] = form.get(field.name) === 'on';
|
|
18
91
|
break;
|
|
19
|
-
case '
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
92
|
+
case 'multiselect':
|
|
93
|
+
if (isClosedMultiselect(field)) {
|
|
94
|
+
// A closed vocabulary submits one form value per checked box.
|
|
95
|
+
data[field.name] = form.getAll(field.name).map(String);
|
|
96
|
+
} else {
|
|
97
|
+
// An open or creatable set is one comma-separated input to trimmed, de-duplicated tags.
|
|
98
|
+
data[field.name] = [
|
|
99
|
+
...new Set(
|
|
100
|
+
String(form.get(field.name) ?? '')
|
|
101
|
+
.split(',')
|
|
102
|
+
.map((tag) => tag.trim())
|
|
103
|
+
.filter(Boolean),
|
|
104
|
+
),
|
|
105
|
+
];
|
|
106
|
+
}
|
|
32
107
|
break;
|
|
33
108
|
case 'image': {
|
|
34
109
|
// The hero submits three sub-fields under one key. An empty src means no hero, so omit the
|
|
@@ -49,6 +124,30 @@ export function frontmatterFromForm(
|
|
|
49
124
|
data[field.name] = value;
|
|
50
125
|
break;
|
|
51
126
|
}
|
|
127
|
+
case 'reference': {
|
|
128
|
+
// One submitted id. An empty value means no edge, so omit the key (like the image arm)
|
|
129
|
+
// rather than committing a blank scalar the extractor would have to skip.
|
|
130
|
+
const id = String(form.get(field.name) ?? '').trim();
|
|
131
|
+
if (id !== '') data[field.name] = id;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case 'array': {
|
|
135
|
+
if (field.item.type === 'reference') {
|
|
136
|
+
// One submitted id per selected element. Drop empties and omit the key on an empty list,
|
|
137
|
+
// so a cleared array leaves no dead key in committed frontmatter.
|
|
138
|
+
const ids = form.getAll(field.name).map(String).map((id) => id.trim()).filter(Boolean);
|
|
139
|
+
if (ids.length > 0) data[field.name] = ids;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
const rows = decodeRows(field.name, field.item, form);
|
|
143
|
+
if (rows.length > 0) data[field.name] = rows;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case 'object': {
|
|
147
|
+
const obj = decodeField(field.name, field, form);
|
|
148
|
+
if (obj !== undefined) data[field.name] = obj;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
52
151
|
default:
|
|
53
152
|
// FormData.get returns null for an absent field; normalize to an empty string so
|
|
54
153
|
// a caller reading a text value never gets null.
|
|
@@ -75,6 +174,30 @@ export function dateInputValue(value: unknown): string {
|
|
|
75
174
|
return '';
|
|
76
175
|
}
|
|
77
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Coerce a frontmatter datetime value to the naive-local, minute-precision `YYYY-MM-DDTHH:mm` an
|
|
179
|
+
* `<input type="datetime-local">` wants. A datetime is round-tripped as TEXT, so a stored value is
|
|
180
|
+
* already this string; the `Date` branch is the fallback for a value gray-matter parsed into a JS
|
|
181
|
+
* `Date` from an unquoted full-ISO scalar. UTC getters read the value back as it was written,
|
|
182
|
+
* avoiding a local-timezone shift.
|
|
183
|
+
*/
|
|
184
|
+
export function datetimeInputValue(value: unknown): string {
|
|
185
|
+
if (value instanceof Date) {
|
|
186
|
+
if (Number.isNaN(value.getTime())) return '';
|
|
187
|
+
const yyyy = value.getUTCFullYear().toString().padStart(4, '0');
|
|
188
|
+
const mm = (value.getUTCMonth() + 1).toString().padStart(2, '0');
|
|
189
|
+
const dd = value.getUTCDate().toString().padStart(2, '0');
|
|
190
|
+
const hh = value.getUTCHours().toString().padStart(2, '0');
|
|
191
|
+
const min = value.getUTCMinutes().toString().padStart(2, '0');
|
|
192
|
+
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
|
|
193
|
+
}
|
|
194
|
+
if (typeof value === 'string') {
|
|
195
|
+
const match = value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/);
|
|
196
|
+
return match ? match[0] : '';
|
|
197
|
+
}
|
|
198
|
+
return '';
|
|
199
|
+
}
|
|
200
|
+
|
|
78
201
|
/**
|
|
79
202
|
* True when `s` is a canonical zero-padded `YYYY-MM-DD` string naming a real calendar date.
|
|
80
203
|
* Rejects a wrong format, an impossible month or day, and a JS date-rollover such as
|
|
@@ -96,6 +219,99 @@ export function isCalendarDate(s: string): boolean {
|
|
|
96
219
|
);
|
|
97
220
|
}
|
|
98
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Canonicalize one raw reference value to its target id. A Date (a YAML-parsed unquoted date-shaped id)
|
|
224
|
+
* becomes its UTC-sliced `YYYY-MM-DD` string, so a stored id never depends on the reader's timezone; a
|
|
225
|
+
* string is trimmed; anything else is the empty string. `validate`, `extractReferenceEdges`, and
|
|
226
|
+
* `formValues` all read through this, so a hand-edited or rewriter-emitted value canonicalizes identically.
|
|
227
|
+
*/
|
|
228
|
+
export function referenceIdFromValue(value: unknown): string {
|
|
229
|
+
if (value instanceof Date) return dateInputValue(value);
|
|
230
|
+
if (typeof value === 'string') return value.trim();
|
|
231
|
+
return '';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Canonicalize a raw `array(reference)` value to its list of target ids. An array maps each element
|
|
236
|
+
* through `referenceIdFromValue`; a lone non-empty scalar is one element (a single id a YAML scalar
|
|
237
|
+
* carries, never dropped to an empty list); anything else is empty. Empty ids are dropped.
|
|
238
|
+
*/
|
|
239
|
+
export function referenceIdsFromValue(value: unknown): string[] {
|
|
240
|
+
const list = Array.isArray(value)
|
|
241
|
+
? value.map(referenceIdFromValue)
|
|
242
|
+
: typeof value === 'string' && value.trim() !== ''
|
|
243
|
+
? [referenceIdFromValue(value)]
|
|
244
|
+
: [];
|
|
245
|
+
return list.filter((id) => id !== '');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The NamedField[] shape formValues iterates, derived from a container's keyed sub-field record. */
|
|
249
|
+
function namedLeaves(record: Record<string, FieldDescriptor>): NamedField[] {
|
|
250
|
+
return Object.entries(record).map(([name, f]) => ({ ...f, name }));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The form value for one leaf array element, reusing the per-type rules so dates/images/etc. coerce identically. */
|
|
254
|
+
function oneLeafFormValue(field: FieldDescriptor, value: unknown): unknown {
|
|
255
|
+
return formValues([{ ...field, name: '_' } as NamedField], { _: value })._;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Coerce a raw multiselect value to the string[] the editor's tag/checkbox inputs read. An array maps
|
|
260
|
+
* each element through String; a lone non-empty scalar (a single tag a YAML scalar carries) is one
|
|
261
|
+
* element rather than dropping to []; anything else is the empty list.
|
|
262
|
+
*/
|
|
263
|
+
function multiselectFormValue(value: unknown): string[] {
|
|
264
|
+
if (Array.isArray(value)) return value.map(String);
|
|
265
|
+
if (typeof value === 'string' && value.trim() !== '') return [value.trim()];
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Coerce parsed frontmatter to the form-ready values the editor inputs expect, one rule per field type. */
|
|
270
|
+
export function formValues(
|
|
271
|
+
fields: NamedField[],
|
|
272
|
+
frontmatter: Record<string, unknown>,
|
|
273
|
+
): Record<string, unknown> {
|
|
274
|
+
const out: Record<string, unknown> = {};
|
|
275
|
+
for (const field of fields) {
|
|
276
|
+
const value = frontmatter[field.name];
|
|
277
|
+
if (field.type === 'date') out[field.name] = dateInputValue(value);
|
|
278
|
+
// A datetime round-trips as text; a value gray-matter parsed into a Date reformats to the
|
|
279
|
+
// naive-local minute-precision string the datetime-local input wants.
|
|
280
|
+
else if (field.type === 'datetime') out[field.name] = datetimeInputValue(value);
|
|
281
|
+
else if (field.type === 'boolean') out[field.name] = value === true;
|
|
282
|
+
else if (field.type === 'multiselect') out[field.name] = multiselectFormValue(value);
|
|
283
|
+
// A hero is a nested object; the default String() arm would corrupt it to '[object Object]'.
|
|
284
|
+
// Hand the stored object back as-is so the editor reads .src/.alt/.caption on open.
|
|
285
|
+
else if (field.type === 'image') out[field.name] = value !== null && typeof value === 'object' ? value : undefined;
|
|
286
|
+
// A reference canonicalizes through the shared coercer: a YAML-parsed Date becomes its UTC-sliced
|
|
287
|
+
// id, never String(Date) timezone garbage.
|
|
288
|
+
else if (field.type === 'reference') out[field.name] = referenceIdFromValue(value);
|
|
289
|
+
// An object recurses one level into a nested form-ready record.
|
|
290
|
+
else if (field.type === 'object') {
|
|
291
|
+
const raw = value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
292
|
+
out[field.name] = formValues(namedLeaves(field.fields), raw);
|
|
293
|
+
}
|
|
294
|
+
else if (field.type === 'array') {
|
|
295
|
+
// An array(reference) canonicalizes through the shared coercer: a lone scalar becomes a
|
|
296
|
+
// single-element list rather than dropping to [], and each element is UTC-sliced.
|
|
297
|
+
if (field.item.type === 'reference') out[field.name] = referenceIdsFromValue(value);
|
|
298
|
+
else {
|
|
299
|
+
const elements = Array.isArray(value) ? value : [];
|
|
300
|
+
const item = field.item;
|
|
301
|
+
out[field.name] = elements.map((el) =>
|
|
302
|
+
item.type === 'object'
|
|
303
|
+
? formValues(namedLeaves(item.fields), el !== null && typeof el === 'object' ? (el as Record<string, unknown>) : {})
|
|
304
|
+
: oneLeafFormValue(item, el),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Every other type is a plain string input: a nullish value reads as empty, anything else
|
|
309
|
+
// stringifies (a string passes through unchanged).
|
|
310
|
+
else out[field.name] = value == null ? '' : String(value);
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
99
315
|
/** Reassemble a markdown file from frontmatter and body for committing. */
|
|
100
316
|
export function serializeMarkdown(frontmatter: object, body: string): string {
|
|
101
317
|
return matter.stringify(body, frontmatter);
|
|
@@ -8,6 +8,7 @@ import { deriveExcerpt } from './excerpt.js';
|
|
|
8
8
|
import { entryIdentity, asString } from './identity.js';
|
|
9
9
|
import { extractCairnLinks, type CairnRef, type LinkResolve } from './links.js';
|
|
10
10
|
import { extractMediaRefs } from './media-refs.js';
|
|
11
|
+
import { extractReferenceEdges, type ReferenceEdge } from './references.js';
|
|
11
12
|
import type { ConceptDescriptor } from './types.js';
|
|
12
13
|
|
|
13
14
|
/** One entry's projection: its identity, routing, draft flag, and outbound cairn: edges. */
|
|
@@ -26,6 +27,13 @@ export interface ManifestEntry {
|
|
|
26
27
|
* the key, and a manifest committed before this field still parses (absent reads as no refs).
|
|
27
28
|
*/
|
|
28
29
|
mediaRefs?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* The typed frontmatter reference edges this entry declares (`{ field, concept, id }` each). The
|
|
32
|
+
* main side of the cross-branch reference index and the reverse `inboundReferences` reader.
|
|
33
|
+
* Additive and optional: an entry with no reference fields omits the key, and a manifest committed
|
|
34
|
+
* before this field still parses (absent reads as no edges).
|
|
35
|
+
*/
|
|
36
|
+
references?: ReferenceEdge[];
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
/** The whole corpus as one committed file. `version` guards a future shape migration. */
|
|
@@ -55,6 +63,9 @@ export function manifestEntryFromFile(descriptor: ConceptDescriptor, file: { pat
|
|
|
55
63
|
// Set mediaRefs only when non-empty, so an image-free entry's row stays byte-identical to before
|
|
56
64
|
// (matching the optional-spread for date and summary).
|
|
57
65
|
const mediaRefs = extractMediaRefs(frontmatter, body, descriptor.fields);
|
|
66
|
+
// Set references only when non-empty, mirroring mediaRefs, so a reference-free entry's row stays
|
|
67
|
+
// byte-identical to a manifest committed before this field.
|
|
68
|
+
const references = extractReferenceEdges(frontmatter, descriptor.fields);
|
|
58
69
|
return {
|
|
59
70
|
id,
|
|
60
71
|
concept: descriptor.id,
|
|
@@ -67,6 +78,7 @@ export function manifestEntryFromFile(descriptor: ConceptDescriptor, file: { pat
|
|
|
67
78
|
draft: frontmatter.draft === true,
|
|
68
79
|
links: extractCairnLinks(body),
|
|
69
80
|
...(mediaRefs.length ? { mediaRefs } : {}),
|
|
81
|
+
...(references.length ? { references } : {}),
|
|
70
82
|
};
|
|
71
83
|
}
|
|
72
84
|
|
|
@@ -79,6 +91,10 @@ function compareRef(a: CairnRef, b: CairnRef): number {
|
|
|
79
91
|
return a.concept.localeCompare(b.concept) || a.id.localeCompare(b.id);
|
|
80
92
|
}
|
|
81
93
|
|
|
94
|
+
function compareEdge(a: ReferenceEdge, b: ReferenceEdge): number {
|
|
95
|
+
return a.field.localeCompare(b.field) || a.concept.localeCompare(b.concept) || a.id.localeCompare(b.id);
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
/**
|
|
83
99
|
* Serialize canonically: entries sorted by concept then id, links sorted and deduped, a fixed key
|
|
84
100
|
* order, two-space pretty, and a trailing newline, so the committed file diffs cleanly in a PR.
|
|
@@ -94,6 +110,9 @@ export function serializeManifest(manifest: Manifest): string {
|
|
|
94
110
|
draft: e.draft,
|
|
95
111
|
links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
|
|
96
112
|
...(e.mediaRefs && e.mediaRefs.length ? { mediaRefs: [...e.mediaRefs].sort() } : {}),
|
|
113
|
+
...(e.references && e.references.length
|
|
114
|
+
? { references: [...e.references].sort(compareEdge).map((r) => ({ field: r.field, concept: r.concept, id: r.id })) }
|
|
115
|
+
: {}),
|
|
97
116
|
}));
|
|
98
117
|
return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
|
|
99
118
|
}
|
|
@@ -128,6 +147,7 @@ export function parseManifest(raw: string): Manifest {
|
|
|
128
147
|
(e.date === undefined || typeof e.date === 'string') &&
|
|
129
148
|
(e.summary === undefined || typeof e.summary === 'string') &&
|
|
130
149
|
(e.mediaRefs === undefined || Array.isArray(e.mediaRefs)) &&
|
|
150
|
+
(e.references === undefined || Array.isArray(e.references)) &&
|
|
131
151
|
Array.isArray(e.links);
|
|
132
152
|
if (!ok) {
|
|
133
153
|
throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
|
|
@@ -142,6 +162,18 @@ export function parseManifest(raw: string): Manifest {
|
|
|
142
162
|
}
|
|
143
163
|
}
|
|
144
164
|
}
|
|
165
|
+
// references is additive and optional: an entry without it parses (the field reads as absent), so
|
|
166
|
+
// a manifest committed before this field still builds. When present, validate each edge's shape,
|
|
167
|
+
// mirroring the link-element validation, so a hand-edited file fails loudly rather than dropping a
|
|
168
|
+
// malformed edge to undefined.
|
|
169
|
+
if (e.references !== undefined) {
|
|
170
|
+
for (const edge of e.references as unknown[]) {
|
|
171
|
+
const r = edge as Record<string, unknown> | null;
|
|
172
|
+
if (!r || typeof r !== 'object' || typeof r.field !== 'string' || typeof r.concept !== 'string' || typeof r.id !== 'string') {
|
|
173
|
+
throw new Error(`content manifest: malformed reference ${JSON.stringify(edge)} in entry ${JSON.stringify(e)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
145
177
|
// Validate each link element's shape, not just that links is an array. inboundLinks and the
|
|
146
178
|
// delete guard read l.concept and l.id, so a string, null, or id-less element would read as
|
|
147
179
|
// undefined and silently drop a real inbound linker. Reject it here instead.
|
|
@@ -227,11 +259,20 @@ export function verifyManifest(built: Manifest, committedRaw: string): void {
|
|
|
227
259
|
version: 1,
|
|
228
260
|
entries: built.entries.map((b) => {
|
|
229
261
|
const c = committedByKey.get(keyOf(b));
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
262
|
+
let entry = b;
|
|
263
|
+
if (entry.mediaRefs && c && c.mediaRefs === undefined) {
|
|
264
|
+
const { mediaRefs: _dropped, ...rest } = entry;
|
|
265
|
+
entry = rest;
|
|
266
|
+
}
|
|
267
|
+
// references is additive: a site whose committed manifest predates the field must still build,
|
|
268
|
+
// even when its content carries reference edges. Drop the built entry's references only when the
|
|
269
|
+
// committed counterpart omits the key, so an un-regenerated site matches while a regenerated one
|
|
270
|
+
// (committed carries references) still detects real drift in that field.
|
|
271
|
+
if (entry.references && c && c.references === undefined) {
|
|
272
|
+
const { references: _dropped, ...rest } = entry;
|
|
273
|
+
entry = rest;
|
|
233
274
|
}
|
|
234
|
-
return
|
|
275
|
+
return entry;
|
|
235
276
|
}),
|
|
236
277
|
};
|
|
237
278
|
const normalizedRaw = serializeManifest(normalized);
|
|
@@ -248,6 +289,28 @@ export function verifyManifest(built: Manifest, committedRaw: string): void {
|
|
|
248
289
|
);
|
|
249
290
|
}
|
|
250
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Throw if any entry's reference edge points at a target absent from the corpus. The match is the
|
|
294
|
+
* `(concept, id)` pair, never id alone, since ids are unique only within a concept. The error names
|
|
295
|
+
* the source entry, the field the edge was declared on, and the missing target, so a build failure
|
|
296
|
+
* reads as a content fix. References have no prerender backstop the way body links do, so this build
|
|
297
|
+
* gate is the only integrity authority; it runs inside the generated virtual-module source (where the
|
|
298
|
+
* built manifest is in scope), beside `verifyManifest`.
|
|
299
|
+
*/
|
|
300
|
+
export function verifyReferences(manifest: Manifest): void {
|
|
301
|
+
const present = new Set(manifest.entries.map(keyOf));
|
|
302
|
+
for (const entry of manifest.entries) {
|
|
303
|
+
for (const edge of entry.references ?? []) {
|
|
304
|
+
const target = `${edge.concept}/${edge.id}`;
|
|
305
|
+
if (!present.has(target)) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`content reference is dangling: ${entry.concept}/${entry.id} field "${edge.field}" points at ${target}, which does not exist.`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
251
314
|
/**
|
|
252
315
|
* Replace the entry with the same concept and id, or add it. Order does not matter, since
|
|
253
316
|
* serializeManifest sorts. This is the save path's incremental patch.
|
|
@@ -283,6 +346,40 @@ export function inboundLinks(manifest: Manifest, concept: string, id: string): I
|
|
|
283
346
|
.map((e) => ({ concept: e.concept, id: e.id, title: e.title, permalink: e.permalink }));
|
|
284
347
|
}
|
|
285
348
|
|
|
349
|
+
/** One inbound referencer: its identity plus the distinct fields through which it references the target. */
|
|
350
|
+
export interface InboundReference {
|
|
351
|
+
concept: string;
|
|
352
|
+
id: string;
|
|
353
|
+
title: string;
|
|
354
|
+
permalink: string;
|
|
355
|
+
/** The distinct fields whose reference edges point at the target, in first-seen order. */
|
|
356
|
+
fields: string[];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Every entry holding a reference edge at the target, excluding the target itself. The match is the
|
|
361
|
+
* `(concept, id)` pair, never id alone, since ids are unique only within a concept (the same keyOf
|
|
362
|
+
* identity upsertEntry and removeEntry use). Each referencer carries the distinct fields through
|
|
363
|
+
* which it points at the target, for the rename repoint and the delete refusal. Pure over the
|
|
364
|
+
* manifest, so the request-time paths and a unit test call it the same way.
|
|
365
|
+
*/
|
|
366
|
+
export function inboundReferences(manifest: Manifest, concept: string, id: string): InboundReference[] {
|
|
367
|
+
const out: InboundReference[] = [];
|
|
368
|
+
for (const e of manifest.entries) {
|
|
369
|
+
if (e.concept === concept && e.id === id) continue;
|
|
370
|
+
const fields: string[] = [];
|
|
371
|
+
for (const edge of e.references ?? []) {
|
|
372
|
+
if (edge.concept === concept && edge.id === id && !fields.includes(edge.field)) {
|
|
373
|
+
fields.push(edge.field);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (fields.length > 0) {
|
|
377
|
+
out.push({ concept: e.concept, id: e.id, title: e.title, permalink: e.permalink, fields });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
286
383
|
/**
|
|
287
384
|
* A resolver backed by manifest targets, for the admin preview. A miss returns undefined, so the
|
|
288
385
|
* render step marks the link broken rather than throwing. The build resolver throws instead.
|
|
@@ -18,7 +18,7 @@ import remarkParse from 'remark-parse';
|
|
|
18
18
|
import remarkGfm from 'remark-gfm';
|
|
19
19
|
import { visit } from 'unist-util-visit';
|
|
20
20
|
import { parseMediaToken } from '../media/reference.js';
|
|
21
|
-
import type {
|
|
21
|
+
import type { ImageValue, NamedField } from './types.js';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* The content hashes one entry references, in first-occurrence order, deduped by hash. Reads the
|
|
@@ -30,7 +30,7 @@ import type { FrontmatterField, ImageValue } from './types.js';
|
|
|
30
30
|
export function extractMediaRefs(
|
|
31
31
|
frontmatter: Record<string, unknown>,
|
|
32
32
|
body: string,
|
|
33
|
-
fields:
|
|
33
|
+
fields: NamedField[],
|
|
34
34
|
): string[] {
|
|
35
35
|
const seen = new Set<string>();
|
|
36
36
|
const hashes: string[] = [];
|
|
@@ -25,6 +25,13 @@ import type { Image, Root } from 'mdast';
|
|
|
25
25
|
import type { ContainerDirective } from 'mdast-util-directive';
|
|
26
26
|
import { parseMediaToken } from '../media/reference.js';
|
|
27
27
|
import { escapeLinkText } from './links.js';
|
|
28
|
+
import {
|
|
29
|
+
type FmLine,
|
|
30
|
+
splitFrontmatter,
|
|
31
|
+
fmLines,
|
|
32
|
+
frontmatterKeyRange,
|
|
33
|
+
escapeForRegExp,
|
|
34
|
+
} from './frontmatter-region.js';
|
|
28
35
|
|
|
29
36
|
/** One repointed reference: which surface it lived on, the old token as written, and the new token. */
|
|
30
37
|
export interface RepointPlacement {
|
|
@@ -73,18 +80,6 @@ function dropOverlappingEdits<T extends { start: number; end: number }>(edits: T
|
|
|
73
80
|
*/
|
|
74
81
|
const MEDIA_TOKEN_SCAN = /media:[A-Za-z0-9._-]+/g;
|
|
75
82
|
|
|
76
|
-
/**
|
|
77
|
-
* Split a leading frontmatter block off the markdown. `fmBlock` is the `---` fenced block including
|
|
78
|
-
* both fences and the trailing newline (empty when there is none); `body` is everything after it.
|
|
79
|
-
* The block leads the document, so a frontmatter offset is already absolute and a body offset needs
|
|
80
|
-
* `fmBlock.length` added. Shared by every arm so they agree on the boundary.
|
|
81
|
-
*/
|
|
82
|
-
function splitFrontmatter(markdown: string): { fmBlock: string; body: string } {
|
|
83
|
-
const m = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
|
84
|
-
const fmBlock = m ? m[0] : '';
|
|
85
|
-
return { fmBlock, body: markdown.slice(fmBlock.length) };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
83
|
/**
|
|
89
84
|
* Parse a doc with the figure-aware pipeline, so the body arm agrees with what remarkFigure renders
|
|
90
85
|
* and can see the enclosing `:::figure` container. Mirrors parseFigureDoc in markdown-format.ts.
|
|
@@ -109,66 +104,6 @@ function inFigure(tree: Root, target: Image): boolean {
|
|
|
109
104
|
return found;
|
|
110
105
|
}
|
|
111
106
|
|
|
112
|
-
/**
|
|
113
|
-
* The split of fmBlock into its lines, each with its block-relative start and end offsets (the end
|
|
114
|
-
* is the index of the trailing newline, or the block length for the last line). Block offsets are
|
|
115
|
-
* already absolute since the frontmatter leads the document.
|
|
116
|
-
*/
|
|
117
|
-
interface FmLine {
|
|
118
|
-
start: number;
|
|
119
|
-
end: number;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Split fmBlock into lines once, so the locator helpers walk a shared structure instead of
|
|
124
|
-
* re-scanning the block per call.
|
|
125
|
-
*/
|
|
126
|
-
function fmLines(fmBlock: string): FmLine[] {
|
|
127
|
-
const lines: FmLine[] = [];
|
|
128
|
-
let pos = 0;
|
|
129
|
-
while (pos <= fmBlock.length) {
|
|
130
|
-
const nl = fmBlock.indexOf('\n', pos);
|
|
131
|
-
const end = nl === -1 ? fmBlock.length : nl;
|
|
132
|
-
lines.push({ start: pos, end });
|
|
133
|
-
if (nl === -1) break;
|
|
134
|
-
pos = nl + 1;
|
|
135
|
-
}
|
|
136
|
-
return lines;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* The inclusive line-index range `[lo, hi]` of the block-style mapping a top-level key opens: the
|
|
141
|
-
* line `^<key>:` at indent 0 through the last line before the next top-level key (or the document
|
|
142
|
-
* end). A flow-style value (`key: { ... }` all on one line) yields a single-line range. Returns null
|
|
143
|
-
* when the key has no top-level line, which a malformed or non-canonical block can cause. Scoping the
|
|
144
|
-
* per-key search to this range is what lets two image fields that share one hash, or an image field
|
|
145
|
-
* whose hash also appears in a sibling text value, resolve to distinct, correct spans.
|
|
146
|
-
*/
|
|
147
|
-
function frontmatterKeyRange(lines: FmLine[], fmBlock: string, key: string): [number, number] | null {
|
|
148
|
-
const opener = new RegExp(`^${escapeForRegExp(key)}:`);
|
|
149
|
-
const topLevelKey = /^[^\s#][^:]*:/;
|
|
150
|
-
const isBoundary = (i: number) => {
|
|
151
|
-
const text = fmBlock.slice(lines[i].start, lines[i].end);
|
|
152
|
-
// A new top-level key or the closing `---` fence ends the current key's block.
|
|
153
|
-
return topLevelKey.test(text) || text === '---';
|
|
154
|
-
};
|
|
155
|
-
let lo = -1;
|
|
156
|
-
for (let i = 1; i < lines.length - 1; i += 1) {
|
|
157
|
-
// Skip the leading `---` fence (line 0) and the trailing empty line after the closing fence.
|
|
158
|
-
if (opener.test(fmBlock.slice(lines[i].start, lines[i].end))) {
|
|
159
|
-
lo = i;
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (lo === -1) return null;
|
|
164
|
-
let hi = lo;
|
|
165
|
-
for (let i = lo + 1; i < lines.length - 1; i += 1) {
|
|
166
|
-
if (isBoundary(i)) break;
|
|
167
|
-
hi = i;
|
|
168
|
-
}
|
|
169
|
-
return [lo, hi];
|
|
170
|
-
}
|
|
171
|
-
|
|
172
107
|
/**
|
|
173
108
|
* A located `src:` line inside a block-style mapping: the line's start and end, its leading indent,
|
|
174
109
|
* and the exact `media:` token's block-relative offsets and text.
|
|
@@ -470,14 +405,6 @@ function bodyAltEdits(body: string, blockLength: number, hash: string, defaultAl
|
|
|
470
405
|
return edits;
|
|
471
406
|
}
|
|
472
407
|
|
|
473
|
-
/**
|
|
474
|
-
* Escape a literal string for safe interpolation into a RegExp source. A key name or an indent is
|
|
475
|
-
* matched literally, so its characters must not act as metacharacters.
|
|
476
|
-
*/
|
|
477
|
-
function escapeForRegExp(literal: string): string {
|
|
478
|
-
return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
479
|
-
}
|
|
480
|
-
|
|
481
408
|
/**
|
|
482
409
|
* Find a sibling key line (`alt:` or `decorative:`) at exactly `indent` within the inclusive
|
|
483
410
|
* line-index range `[lo, hi]` of one mapping. The range is the mapping's own block, so the search
|