@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
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// cairn-cms: the shared field constraint rules
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// rules stay deterministic on Workers.
|
|
1
|
+
// cairn-cms: the shared field constraint rules the `fieldset` validator calls. They live apart from
|
|
2
|
+
// the validator as pure helpers, so the constraint wording and the first-failing-rule-wins order are
|
|
3
|
+
// stated once. No I/O and no clock reads, so the rules stay deterministic on Workers.
|
|
5
4
|
|
|
6
5
|
/** Compile a field pattern once, throwing a labeled error when the source is not a valid regex. */
|
|
7
6
|
export function compilePattern(source: string, label: string): RegExp {
|
|
@@ -44,6 +44,8 @@ export interface MultiselectField extends FieldBase {
|
|
|
44
44
|
options?: readonly string[];
|
|
45
45
|
/** Allow the author to add values not in the list. */
|
|
46
46
|
creatable?: boolean;
|
|
47
|
+
/** Placeholder text for the open/creatable comma-separated input (freetags parity). */
|
|
48
|
+
placeholder?: string;
|
|
47
49
|
/** Mark the field as a site-wide taxonomy whose values pool across entries. */
|
|
48
50
|
taxonomy?: boolean;
|
|
49
51
|
}
|
|
@@ -75,12 +77,41 @@ export interface DatetimeField extends FieldBase {
|
|
|
75
77
|
export interface BooleanField extends FieldBase {
|
|
76
78
|
type: 'boolean';
|
|
77
79
|
}
|
|
80
|
+
/** A glyph chosen from the adapter's icon set; the stored value is the glyph's name. */
|
|
81
|
+
export interface IconField extends FieldBase {
|
|
82
|
+
type: 'icon';
|
|
83
|
+
}
|
|
78
84
|
/** A hero image whose stored value is the nested ImageValue object. */
|
|
79
85
|
export interface ImageField extends FieldBase {
|
|
80
86
|
type: 'image';
|
|
81
87
|
/** Whether this field feeds the social-card image. */
|
|
82
88
|
seo?: boolean;
|
|
83
89
|
}
|
|
90
|
+
/** A group of leaf fields, stored as a nested object. Holds only leaves (no nested container). */
|
|
91
|
+
export interface ObjectField extends Omit<FieldBase, 'label'> {
|
|
92
|
+
type: 'object';
|
|
93
|
+
/**
|
|
94
|
+
* Optional group label. An object inside an array is labeled by the array (and summarized per row by
|
|
95
|
+
* itemLabel), so it may omit this; a top-level object supplies it for the group legend.
|
|
96
|
+
*/
|
|
97
|
+
label?: string;
|
|
98
|
+
/** The leaf fields this group holds, keyed by frontmatter sub-key. */
|
|
99
|
+
fields: Record<string, FieldDescriptor>;
|
|
100
|
+
}
|
|
101
|
+
/** A single edge to one entry of a named concept, stored as that target's permanent id. */
|
|
102
|
+
export interface ReferenceField extends FieldBase {
|
|
103
|
+
type: 'reference';
|
|
104
|
+
/** The concept whose entries this field references. */
|
|
105
|
+
concept: string;
|
|
106
|
+
}
|
|
107
|
+
/** A repeatable field whose stored value is a list of its item's values. */
|
|
108
|
+
export interface ArrayField extends FieldBase {
|
|
109
|
+
type: 'array';
|
|
110
|
+
/** The descriptor each list element conforms to: a leaf, or a flat object of leaves. */
|
|
111
|
+
item: FieldDescriptor;
|
|
112
|
+
/** A label for one row, shown beside the add and remove controls. */
|
|
113
|
+
itemLabel?: string;
|
|
114
|
+
}
|
|
84
115
|
/** The plain-data descriptor union the form, validator, and inference all read. Grows per task. */
|
|
85
116
|
export type FieldDescriptor =
|
|
86
117
|
| TextField
|
|
@@ -93,7 +124,11 @@ export type FieldDescriptor =
|
|
|
93
124
|
| DateField
|
|
94
125
|
| DatetimeField
|
|
95
126
|
| BooleanField
|
|
96
|
-
|
|
|
127
|
+
| IconField
|
|
128
|
+
| ImageField
|
|
129
|
+
| ObjectField
|
|
130
|
+
| ReferenceField
|
|
131
|
+
| ArrayField;
|
|
97
132
|
|
|
98
133
|
/**
|
|
99
134
|
* The constructor namespace a concept declares its fields with. Each constructor captures its
|
|
@@ -122,6 +157,22 @@ export const fields = {
|
|
|
122
157
|
datetime: <const O extends Omit<DatetimeField, 'type'>>(o: O): DatetimeField & O => ({ type: 'datetime', ...o }),
|
|
123
158
|
/** A boolean checkbox field. */
|
|
124
159
|
boolean: <const O extends Omit<BooleanField, 'type'>>(o: O): BooleanField & O => ({ type: 'boolean', ...o }),
|
|
160
|
+
/** An icon field whose value is a glyph name from the adapter's icon set. */
|
|
161
|
+
icon: <const O extends Omit<IconField, 'type'>>(o: O): IconField & O => ({ type: 'icon', ...o }),
|
|
125
162
|
/** An image field whose value is the nested ImageValue object. */
|
|
126
163
|
image: <const O extends Omit<ImageField, 'type'>>(o: O): ImageField & O => ({ type: 'image', ...o }),
|
|
164
|
+
/** A group of leaf fields, preserving each leaf's type for inference. Label is optional (the array labels a row group). */
|
|
165
|
+
object: <const F extends Record<string, FieldDescriptor>, const O extends Omit<ObjectField, 'type' | 'fields'>>(
|
|
166
|
+
o: { fields: F } & O,
|
|
167
|
+
): ObjectField & { fields: F } & O => ({ type: 'object', ...o }),
|
|
168
|
+
/** A single reference field storing one target entry's permanent id. */
|
|
169
|
+
reference: <const O extends Omit<ReferenceField, 'type'>>(o: O): ReferenceField & O => ({ type: 'reference', ...o }),
|
|
170
|
+
/**
|
|
171
|
+
* A repeatable field over one item descriptor, preserving the item type for inference. The item is
|
|
172
|
+
* a leaf, or a flat object of leaves; `fieldset` rejects deeper nesting at declaration.
|
|
173
|
+
*/
|
|
174
|
+
array: <const I extends FieldDescriptor, const O extends Omit<ArrayField, 'type' | 'item'>>(
|
|
175
|
+
item: I,
|
|
176
|
+
o?: O,
|
|
177
|
+
): ArrayField & { item: I } & O => ({ type: 'array', item, ...(o as O) }),
|
|
127
178
|
};
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// cairn-cms: the fieldset primitive (Contract v2). A key-to-descriptor
|
|
2
|
-
// carrying the descriptors as plain data, a server-derived validator, and the
|
|
3
|
-
// conformance property. The validator coerces per type, drops an empty optional field,
|
|
4
|
-
// field-keyed errors or normalized data.
|
|
5
|
-
//
|
|
1
|
+
// cairn-cms: the fieldset primitive (Contract v2), the one live field system. A key-to-descriptor
|
|
2
|
+
// record becomes a schema carrying the descriptors as plain data, a server-derived validator, and the
|
|
3
|
+
// Standard Schema conformance property. The validator coerces per type, drops an empty optional field,
|
|
4
|
+
// and returns field-keyed errors or normalized data. The adapter contract, the editor form, the
|
|
5
|
+
// delivery inference, and the media extractor all read this.
|
|
6
6
|
import type { FieldDescriptor, ImageValue } from './fields.js';
|
|
7
|
-
import type { ValidationResult } from './types.js';
|
|
8
|
-
import type { StandardInput, StandardSchemaV1 } from './schema.js';
|
|
9
|
-
import { dateInputValue, isCalendarDate } from './frontmatter.js';
|
|
7
|
+
import type { ValidationIssue, ValidationResult } from './types.js';
|
|
8
|
+
import type { StandardInput, StandardSchemaV1 } from './standard-schema.js';
|
|
9
|
+
import { datetimeInputValue, dateInputValue, isCalendarDate, referenceIdsFromValue } from './frontmatter.js';
|
|
10
10
|
import { compilePattern, dateBoundsError, patternError, stringLengthError } from './field-rules.js';
|
|
11
|
+
import { isValidId } from './ids.js';
|
|
11
12
|
|
|
12
13
|
/** Accept any URL using http or https with a non-empty rest, mirroring the conservative form check. */
|
|
13
14
|
const URL_RE = /^https?:\/\/\S+$/;
|
|
@@ -15,11 +16,19 @@ const URL_RE = /^https?:\/\/\S+$/;
|
|
|
15
16
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Function-valued behavior a field descriptor cannot carry as plain data, keyed by field name. A
|
|
20
|
+
* `validate` runs cross-field after per-field coercion; an `itemLabel` derives an array row's label.
|
|
21
|
+
* Resident in the app bundle, never in the `load` payload.
|
|
21
22
|
*/
|
|
22
|
-
export
|
|
23
|
+
export interface FieldBehavior {
|
|
24
|
+
/** A cross-field validator: returns an error string, or null when valid. `siblings` is the raw input record. */
|
|
25
|
+
validate?(value: unknown, siblings: Record<string, unknown>): string | null;
|
|
26
|
+
/** Derive an array row's label from its item value and zero-based index. */
|
|
27
|
+
itemLabel?(item: Record<string, unknown>, index: number): string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The behavior table co-bundled with a fieldset, keyed by field name. Empty for a behavior-free fieldset. */
|
|
31
|
+
export type BehaviorTable = Record<string, FieldBehavior>;
|
|
23
32
|
|
|
24
33
|
/**
|
|
25
34
|
* Options for `fieldset`. `refine` runs after the per-field coercion and constraints pass, for
|
|
@@ -28,6 +37,8 @@ export type BehaviorTable = Record<string, never>;
|
|
|
28
37
|
*/
|
|
29
38
|
export interface FieldsetOptions {
|
|
30
39
|
refine?: (data: Record<string, unknown>, body: string) => Record<string, string> | undefined;
|
|
40
|
+
/** Function-valued per-field behavior, keyed by field name. Each key must name a declared field. */
|
|
41
|
+
behavior?: BehaviorTable;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
/**
|
|
@@ -57,58 +68,40 @@ type ValueOf<D extends FieldDescriptor> = D extends { type: 'number' }
|
|
|
57
68
|
? boolean
|
|
58
69
|
: D extends { type: 'image' }
|
|
59
70
|
? ImageValue
|
|
60
|
-
: D extends { type: '
|
|
61
|
-
?
|
|
62
|
-
: D extends { type: '
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
: D extends { type: 'object'; fields: infer F extends Record<string, FieldDescriptor> }
|
|
72
|
+
? InferRecord<F>
|
|
73
|
+
: D extends { type: 'array'; item: infer I extends FieldDescriptor }
|
|
74
|
+
? ValueOf<I>[]
|
|
75
|
+
: D extends { type: 'select'; options: readonly (infer O extends string)[] }
|
|
76
|
+
? O
|
|
77
|
+
: D extends { type: 'multiselect'; options: readonly (infer O extends string)[] }
|
|
78
|
+
? O[]
|
|
79
|
+
: D extends { type: 'multiselect' }
|
|
80
|
+
? string[]
|
|
81
|
+
: string;
|
|
67
82
|
|
|
68
83
|
/** Flatten an intersection into a single readable object type. */
|
|
69
84
|
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
70
85
|
|
|
86
|
+
/** Drop an index signature so a captured literal record infers its own keys only, not `[x: string]`. */
|
|
87
|
+
type RemoveIndex<T> = {
|
|
88
|
+
[K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
|
|
89
|
+
};
|
|
90
|
+
|
|
71
91
|
/**
|
|
72
92
|
* The normalized frontmatter type inferred from a fieldset's descriptor record. A descriptor
|
|
73
|
-
* declared `required: true` is a required key; every other descriptor is optional.
|
|
93
|
+
* declared `required: true` is a required key; every other descriptor is optional. The captured
|
|
94
|
+
* literal record carries an index signature (the constructor's `Record<string, FieldDescriptor>`
|
|
95
|
+
* intersected with the literal), so strip it first or every nested key would also infer `[x: string]`.
|
|
74
96
|
*/
|
|
75
|
-
type
|
|
76
|
-
{ -readonly [K in keyof R as R[K] extends { required: true } ? K : never]: ValueOf<R[K]> } & {
|
|
77
|
-
-readonly [K in keyof R as R[K] extends { required: true } ? never : K]?: ValueOf<R[K]>;
|
|
97
|
+
type InferRecord<RR extends Record<string, FieldDescriptor>, R = RemoveIndex<RR>> = Prettify<
|
|
98
|
+
{ -readonly [K in keyof R as R[K] extends { required: true } ? K : never]: ValueOf<R[K] extends FieldDescriptor ? R[K] : never> } & {
|
|
99
|
+
-readonly [K in keyof R as R[K] extends { required: true } ? never : K]?: ValueOf<R[K] extends FieldDescriptor ? R[K] : never>;
|
|
78
100
|
}
|
|
79
101
|
>;
|
|
80
102
|
|
|
81
103
|
/** Extract the inferred frontmatter type from a `Fieldset`. */
|
|
82
|
-
export type InferFieldset<S> = S extends Fieldset<infer R> ?
|
|
83
|
-
|
|
84
|
-
// Coerce one image value to the stored `{ src, alt, caption?, decorative? }` shape, ported from
|
|
85
|
-
// validate.ts. Default a missing alt to empty (alt is debt, never a save block), trim and drop a
|
|
86
|
-
// blank caption, keep decorative only when an explicit true, and drop the whole key when src is empty.
|
|
87
|
-
// A required image with an empty src is the one error this arm raises.
|
|
88
|
-
function coerceImage(
|
|
89
|
-
field: Extract<FieldDescriptor, { type: 'image' }>,
|
|
90
|
-
key: string,
|
|
91
|
-
value: unknown,
|
|
92
|
-
data: Record<string, unknown>,
|
|
93
|
-
errors: Record<string, string>,
|
|
94
|
-
): void {
|
|
95
|
-
let src = '';
|
|
96
|
-
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
97
|
-
const obj = value as Record<string, unknown>;
|
|
98
|
-
src = typeof obj.src === 'string' ? obj.src.trim() : '';
|
|
99
|
-
if (src !== '') {
|
|
100
|
-
const normalized: ImageValue = {
|
|
101
|
-
src,
|
|
102
|
-
alt: typeof obj.alt === 'string' ? obj.alt : '',
|
|
103
|
-
};
|
|
104
|
-
const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
|
|
105
|
-
if (caption !== '') normalized.caption = caption;
|
|
106
|
-
if (obj.decorative === true) normalized.decorative = true;
|
|
107
|
-
data[key] = normalized;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (field.required && src === '') errors[key] = `${field.label} is required`;
|
|
111
|
-
}
|
|
104
|
+
export type InferFieldset<S> = S extends Fieldset<infer R> ? InferRecord<R> : never;
|
|
112
105
|
|
|
113
106
|
// Coerce a raw value to the trimmed string the empty check and constraints run on. A parsed value may
|
|
114
107
|
// arrive from parseMarkdown, not only a form string: a Date on a date or datetime field, a JS number on
|
|
@@ -116,36 +109,97 @@ function coerceImage(
|
|
|
116
109
|
// carries; a NaN or non-finite number stays '' and routes to the number error in validateField.
|
|
117
110
|
function coerceToText(type: FieldDescriptor['type'], value: unknown): string {
|
|
118
111
|
if (type === 'date' && value instanceof Date) return dateInputValue(value);
|
|
119
|
-
if (type === 'datetime' && value instanceof Date) return value
|
|
112
|
+
if (type === 'datetime' && value instanceof Date) return datetimeInputValue(value);
|
|
120
113
|
if (type === 'number' && typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
121
114
|
if (typeof value === 'string') return value.trim();
|
|
122
115
|
return '';
|
|
123
116
|
}
|
|
124
117
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
118
|
+
/** The outcome of validating one field: the stored value when present, plus any located issues. */
|
|
119
|
+
interface FieldOutcome {
|
|
120
|
+
value?: unknown;
|
|
121
|
+
issues: ValidationIssue[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Build the structural key for a path by dropping numeric (row-index) segments, so a nested text
|
|
125
|
+
// field's compiled pattern is found regardless of which row it sits in: ['faq', 2, 'code'] -> 'faq.code'.
|
|
126
|
+
function structuralKey(path: (string | number)[]): string {
|
|
127
|
+
return path.filter((seg) => typeof seg === 'string').join('.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Validate one descriptor against its raw value and return its outcome. Empty or absent is
|
|
131
|
+
// "not provided" and is read BEFORE type coercion, uniformly: a required field returns an issue, an
|
|
132
|
+
// optional field drops (no value, no issue). Only a non-empty value is coerced. boolean is the
|
|
133
|
+
// exception: true stores true, anything else omits the value. number relies on the empty-first drop so
|
|
134
|
+
// an empty optional number never becomes Number('') === 0. A container (object, array) recurses one
|
|
135
|
+
// level, appending the leaf key or element index to `path` for each nested issue.
|
|
130
136
|
function validateField(
|
|
131
|
-
|
|
137
|
+
path: (string | number)[],
|
|
132
138
|
field: FieldDescriptor,
|
|
133
139
|
value: unknown,
|
|
134
|
-
data: Record<string, unknown>,
|
|
135
|
-
errors: Record<string, string>,
|
|
136
140
|
patterns: Map<string, RegExp>,
|
|
137
|
-
):
|
|
138
|
-
|
|
141
|
+
): FieldOutcome {
|
|
142
|
+
const label = field.label ?? '';
|
|
143
|
+
|
|
144
|
+
// object: validate each leaf one level down, assembling a nested object value and concatenating
|
|
145
|
+
// issues with the leaf key appended to the path. An empty (all-leaves-dropped) object omits the
|
|
146
|
+
// value; a required empty object is an error on the object's own path.
|
|
147
|
+
if (field.type === 'object') {
|
|
148
|
+
const obj: Record<string, unknown> = {};
|
|
149
|
+
const issues: ValidationIssue[] = [];
|
|
150
|
+
const raw = value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
151
|
+
for (const [leafKey, leaf] of Object.entries(field.fields)) {
|
|
152
|
+
const outcome = validateField([...path, leafKey], leaf, raw[leafKey], patterns);
|
|
153
|
+
issues.push(...outcome.issues);
|
|
154
|
+
if ('value' in outcome) obj[leafKey] = outcome.value;
|
|
155
|
+
}
|
|
156
|
+
if (issues.length > 0) return { issues };
|
|
157
|
+
if (Object.keys(obj).length === 0) {
|
|
158
|
+
return field.required ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
|
|
159
|
+
}
|
|
160
|
+
return { value: obj, issues };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// array: a reference item keeps the shipped id-list path; any other item recurses per element with
|
|
164
|
+
// the element index appended to the path. A required empty list errors on the array's own path.
|
|
165
|
+
if (field.type === 'array') {
|
|
166
|
+
if (field.item.type === 'reference') {
|
|
167
|
+
// array(reference): coerceToText returns '' for an array, so the empty-first drop below would
|
|
168
|
+
// silently lose an optional list or falsely error a required one. The canonicalizer coerces a
|
|
169
|
+
// lone scalar to one element and a Date element to its id. Each element must pass isValidId (the
|
|
170
|
+
// item's reference rule this phase); a required empty list errors; the value is set only when the
|
|
171
|
+
// list is non-empty.
|
|
172
|
+
const list = referenceIdsFromValue(value);
|
|
173
|
+
if (field.required && list.length === 0) return { issues: [{ path, message: `${label} is required` }] };
|
|
174
|
+
const invalid = list.find((id) => !isValidId(id));
|
|
175
|
+
if (invalid !== undefined) return { issues: [{ path, message: `${label} is not a valid reference` }] };
|
|
176
|
+
return list.length > 0 ? { value: list, issues: [] } : { issues: [] };
|
|
177
|
+
}
|
|
178
|
+
const elements = Array.isArray(value) ? value : [];
|
|
179
|
+
const out: unknown[] = [];
|
|
180
|
+
const issues: ValidationIssue[] = [];
|
|
181
|
+
elements.forEach((element, i) => {
|
|
182
|
+
const outcome = validateField([...path, i], field.item, element, patterns);
|
|
183
|
+
issues.push(...outcome.issues);
|
|
184
|
+
if ('value' in outcome) out.push(outcome.value);
|
|
185
|
+
});
|
|
186
|
+
if (issues.length > 0) return { issues };
|
|
187
|
+
if (out.length === 0) {
|
|
188
|
+
return field.required ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
|
|
189
|
+
}
|
|
190
|
+
return { value: out, issues };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// boolean: presence is the value; an unchecked or absent box omits the value (no draft: false noise).
|
|
139
194
|
if (field.type === 'boolean') {
|
|
140
|
-
|
|
141
|
-
return;
|
|
195
|
+
return value === true ? { value: true, issues: [] } : { issues: [] };
|
|
142
196
|
}
|
|
143
197
|
|
|
144
198
|
// multiselect: a string array; drop empties, reject an unknown value when options is closed. An empty
|
|
145
|
-
// list omits the
|
|
146
|
-
// non-empty scalar (a single tag a YAML scalar carries) coerces to a single-element list, rather
|
|
147
|
-
// dropping to [] and reading as "required" while present. An empty string or a
|
|
148
|
-
// stays the empty list.
|
|
199
|
+
// list omits the value (a required empty errors); the array path is the one non-string coercion. A
|
|
200
|
+
// lone non-empty scalar (a single tag a YAML scalar carries) coerces to a single-element list, rather
|
|
201
|
+
// than dropping to [] and reading as "required" while present. An empty string or a
|
|
202
|
+
// non-string-non-array stays the empty list.
|
|
149
203
|
if (field.type === 'multiselect') {
|
|
150
204
|
let raw: string[];
|
|
151
205
|
if (Array.isArray(value)) raw = value.map(String);
|
|
@@ -153,25 +207,38 @@ function validateField(
|
|
|
153
207
|
else raw = [];
|
|
154
208
|
const list = raw.map((v) => v.trim()).filter((v) => v !== '');
|
|
155
209
|
if (field.required && list.length === 0) {
|
|
156
|
-
|
|
157
|
-
return;
|
|
210
|
+
return { issues: [{ path, message: `${label} is required` }] };
|
|
158
211
|
}
|
|
159
212
|
const { options } = field;
|
|
160
213
|
if (options) {
|
|
161
214
|
const unknown = list.find((v) => !options.includes(v));
|
|
162
215
|
if (unknown !== undefined) {
|
|
163
|
-
|
|
164
|
-
return;
|
|
216
|
+
return { issues: [{ path, message: `${label} contains an unknown value: ${unknown}` }] };
|
|
165
217
|
}
|
|
166
218
|
}
|
|
167
|
-
|
|
168
|
-
return;
|
|
219
|
+
return list.length > 0 ? { value: list, issues: [] } : { issues: [] };
|
|
169
220
|
}
|
|
170
221
|
|
|
171
|
-
// image: the nested object arm, dropping the
|
|
222
|
+
// image: the nested object arm, dropping the value on empty src. Default a missing alt to empty (alt
|
|
223
|
+
// is debt, never a save block), trim and drop a blank caption, keep decorative only when an explicit
|
|
224
|
+
// true. A required image with an empty src is the one error this arm raises.
|
|
172
225
|
if (field.type === 'image') {
|
|
173
|
-
|
|
174
|
-
|
|
226
|
+
let src = '';
|
|
227
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
228
|
+
const obj = value as Record<string, unknown>;
|
|
229
|
+
src = typeof obj.src === 'string' ? obj.src.trim() : '';
|
|
230
|
+
if (src !== '') {
|
|
231
|
+
const normalized: ImageValue = {
|
|
232
|
+
src,
|
|
233
|
+
alt: typeof obj.alt === 'string' ? obj.alt : '',
|
|
234
|
+
};
|
|
235
|
+
const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
|
|
236
|
+
if (caption !== '') normalized.caption = caption;
|
|
237
|
+
if (obj.decorative === true) normalized.decorative = true;
|
|
238
|
+
return { value: normalized, issues: [] };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return field.required && src === '' ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
|
|
175
242
|
}
|
|
176
243
|
|
|
177
244
|
// Every other type is "not provided when empty" first, then coerced. `coerceToText` turns a parsed
|
|
@@ -179,67 +246,124 @@ function validateField(
|
|
|
179
246
|
// datetime field, a number on a number field) is not read as empty.
|
|
180
247
|
const text = coerceToText(field.type, value);
|
|
181
248
|
if (text === '') {
|
|
182
|
-
|
|
183
|
-
return;
|
|
249
|
+
return field.required ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
|
|
184
250
|
}
|
|
185
251
|
|
|
252
|
+
const key = structuralKey(path);
|
|
186
253
|
switch (field.type) {
|
|
187
254
|
case 'number': {
|
|
188
255
|
const n = Number(text);
|
|
189
256
|
// Reject NaN and the non-finite values Number() yields for "Infinity"/"1e400", which an
|
|
190
257
|
// isNaN check alone would pass through and commit as a YAML .inf scalar.
|
|
191
|
-
if (!Number.isFinite(n))
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
break;
|
|
258
|
+
if (!Number.isFinite(n)) return { issues: [{ path, message: `${label} must be a number` }] };
|
|
259
|
+
if (field.integer && !Number.isInteger(n)) return { issues: [{ path, message: `${label} must be a whole number` }] };
|
|
260
|
+
if (field.min != null && n < field.min) return { issues: [{ path, message: `${label} must be at least ${field.min}` }] };
|
|
261
|
+
if (field.max != null && n > field.max) return { issues: [{ path, message: `${label} must be at most ${field.max}` }] };
|
|
262
|
+
return { value: n, issues: [] };
|
|
197
263
|
}
|
|
198
264
|
case 'select': {
|
|
199
|
-
if (!field.options.includes(text))
|
|
200
|
-
|
|
201
|
-
break;
|
|
265
|
+
if (!field.options.includes(text)) return { issues: [{ path, message: `${label} contains an unknown value: ${text}` }] };
|
|
266
|
+
return { value: text, issues: [] };
|
|
202
267
|
}
|
|
203
268
|
case 'url': {
|
|
204
|
-
if (!URL_RE.test(text))
|
|
205
|
-
|
|
206
|
-
break;
|
|
269
|
+
if (!URL_RE.test(text)) return { issues: [{ path, message: `${label} is not a valid URL` }] };
|
|
270
|
+
return { value: text, issues: [] };
|
|
207
271
|
}
|
|
208
272
|
case 'email': {
|
|
209
|
-
if (!EMAIL_RE.test(text))
|
|
210
|
-
|
|
211
|
-
break;
|
|
273
|
+
if (!EMAIL_RE.test(text)) return { issues: [{ path, message: `${label} is not a valid email address` }] };
|
|
274
|
+
return { value: text, issues: [] };
|
|
212
275
|
}
|
|
213
276
|
case 'date': {
|
|
214
|
-
if (!isCalendarDate(text)) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
break;
|
|
277
|
+
if (!isCalendarDate(text)) return { issues: [{ path, message: `${label} must be a valid date (YYYY-MM-DD)` }] };
|
|
278
|
+
const boundsError = dateBoundsError(text, field, label);
|
|
279
|
+
if (boundsError != null) return { issues: [{ path, message: boundsError }] };
|
|
280
|
+
return { value: text, issues: [] };
|
|
281
|
+
}
|
|
282
|
+
case 'reference': {
|
|
283
|
+
// A scalar edge: the empty-first drop above already handled an absent optional, so a non-empty
|
|
284
|
+
// value must be a valid id. An invalid token is a corrupted edge, not a coercible value.
|
|
285
|
+
if (!isValidId(text)) return { issues: [{ path, message: `${label} is not a valid reference` }] };
|
|
286
|
+
return { value: text, issues: [] };
|
|
225
287
|
}
|
|
226
288
|
default: {
|
|
227
289
|
// text, textarea, datetime: a trimmed non-empty string. text and textarea also enforce the
|
|
228
290
|
// string-length and pattern constraints (v1 parity); datetime stays a plain string for now,
|
|
229
291
|
// since its bounds are out of scope this pass and v1 has no datetime equivalent to match.
|
|
230
292
|
if (field.type === 'text' || field.type === 'textarea') {
|
|
231
|
-
const lengthError = stringLengthError(text, field,
|
|
232
|
-
if (lengthError != null) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
293
|
+
const lengthError = stringLengthError(text, field, label);
|
|
294
|
+
if (lengthError != null) return { issues: [{ path, message: lengthError }] };
|
|
295
|
+
const formatError = patternError(text, patterns.get(key), label);
|
|
296
|
+
if (formatError != null) return { issues: [{ path, message: formatError }] };
|
|
297
|
+
}
|
|
298
|
+
return { value: text, issues: [] };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// At most one image field may feed the social card, so the og:image is unambiguous. A v2 fieldset
|
|
304
|
+
// marks that field with an explicit `seo: true`; there is no field-name default, since the record key
|
|
305
|
+
// is arbitrary. Two seo images is a site config error, so fail loudly at declaration (v1 parity).
|
|
306
|
+
// The delivery seo reader resolves the social card off a hardcoded top-level key list, so a nested
|
|
307
|
+
// seo image cannot resolve at delivery; this phase forbids seo: true inside any container and defers
|
|
308
|
+
// nested seo to the pass that generalizes delivery seo resolution.
|
|
309
|
+
function checkSeoImageFields(record: Record<string, FieldDescriptor>): void {
|
|
310
|
+
const seo: string[] = [];
|
|
311
|
+
for (const [key, field] of Object.entries(record)) {
|
|
312
|
+
if (field.type === 'image' && field.seo === true) seo.push(`"${key}"`);
|
|
313
|
+
else if (field.type === 'object') {
|
|
314
|
+
for (const [leafKey, leaf] of Object.entries(field.fields)) {
|
|
315
|
+
if (leaf.type === 'image' && leaf.seo === true) {
|
|
316
|
+
throw new Error(`cairn: the image "${key}.${leafKey}" sets seo: true, but a nested seo image is not supported this phase. Put the social-card image at the top level.`);
|
|
240
317
|
}
|
|
241
318
|
}
|
|
242
|
-
|
|
319
|
+
} else if (field.type === 'array') {
|
|
320
|
+
const item = field.item;
|
|
321
|
+
const nested = (item.type === 'image' && item.seo === true)
|
|
322
|
+
|| (item.type === 'object' && Object.values(item.fields).some((l) => l.type === 'image' && l.seo === true));
|
|
323
|
+
if (nested) {
|
|
324
|
+
throw new Error(`cairn: the array field "${key}" declares an seo image, but an array would mean one social card per row. Put seo: true on a top-level image.`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (seo.length > 1) {
|
|
329
|
+
throw new Error(`cairn: a concept declares at most one SEO image field, but found ${seo.length} (${seo.join(', ')}). Set seo: false on all but one.`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// A leaf is any non-container descriptor. A container (object, array) may hold leaves one level deep only.
|
|
334
|
+
function isLeaf(field: FieldDescriptor): boolean {
|
|
335
|
+
return field.type !== 'object' && field.type !== 'array';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Enforce the one-level nesting cap, the no-reference-in-object deferral, and the no-dot-in-key rule, all
|
|
339
|
+
// loudly at declaration. A deeper nesting, a nested reference, or a dotted key would otherwise mis-save or
|
|
340
|
+
// mis-decode at the edge, so fail here.
|
|
341
|
+
function checkContainerNesting(record: Record<string, FieldDescriptor>): void {
|
|
342
|
+
const checkKey = (k: string, where: string): void => {
|
|
343
|
+
if (k.includes('.')) throw new Error(`cairn: ${where} "${k}" must not contain a dot; field keys address the nested form by dotted path.`);
|
|
344
|
+
};
|
|
345
|
+
const checkObjectLeaves = (fieldsRecord: Record<string, FieldDescriptor>, where: string): void => {
|
|
346
|
+
for (const [k, leaf] of Object.entries(fieldsRecord)) {
|
|
347
|
+
checkKey(k, where);
|
|
348
|
+
if (!isLeaf(leaf)) {
|
|
349
|
+
throw new Error(`cairn: ${where} "${k}" must be a leaf field; containers nest one level only.`);
|
|
350
|
+
}
|
|
351
|
+
if (leaf.type === 'reference') {
|
|
352
|
+
throw new Error(`cairn: ${where} "${k}" is a reference; a reference inside an object is not supported this phase. Model it as the parent's own concept, or use a top-level array(reference).`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
for (const [key, field] of Object.entries(record)) {
|
|
357
|
+
checkKey(key, 'the field');
|
|
358
|
+
if (field.type === 'object') {
|
|
359
|
+
checkObjectLeaves(field.fields, `the object field "${key}" sub-field`);
|
|
360
|
+
} else if (field.type === 'array') {
|
|
361
|
+
const item = field.item;
|
|
362
|
+
if (item.type === 'object') {
|
|
363
|
+
checkObjectLeaves(item.fields, `the array field "${key}" row sub-field`);
|
|
364
|
+
} else if (!isLeaf(item)) {
|
|
365
|
+
throw new Error(`cairn: the array field "${key}" item must be a leaf or a flat object; an array of arrays is not allowed.`);
|
|
366
|
+
}
|
|
243
367
|
}
|
|
244
368
|
}
|
|
245
369
|
}
|
|
@@ -253,23 +377,62 @@ export function fieldset<const R extends Record<string, FieldDescriptor>>(
|
|
|
253
377
|
record: R,
|
|
254
378
|
options: FieldsetOptions = {},
|
|
255
379
|
): Fieldset<R> {
|
|
380
|
+
checkSeoImageFields(record);
|
|
381
|
+
checkContainerNesting(record);
|
|
382
|
+
for (const key of Object.keys(options.behavior ?? {})) {
|
|
383
|
+
if (!(key in record)) throw new Error(`cairn: behavior names "${key}", which is not a declared field.`);
|
|
384
|
+
}
|
|
256
385
|
// Compile each text/textarea pattern once at construction, so a malformed pattern fails loudly here
|
|
257
|
-
// (mirroring v1's compilePatterns) rather than on every save. Keyed by
|
|
386
|
+
// (mirroring v1's compilePatterns) rather than on every save. Keyed by the structural path
|
|
387
|
+
// ('faq.code', 'address.zip') so a nested leaf's compiled pattern is found regardless of row index,
|
|
388
|
+
// recursing one level into an object and an array(object).
|
|
258
389
|
const patterns = new Map<string, RegExp>();
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
390
|
+
const compilePatternsIn = (rec: Record<string, FieldDescriptor>, prefix: string[]): void => {
|
|
391
|
+
for (const [k, f] of Object.entries(rec)) {
|
|
392
|
+
if ((f.type === 'text' || f.type === 'textarea') && f.pattern != null) {
|
|
393
|
+
patterns.set([...prefix, k].join('.'), compilePattern(f.pattern, f.label));
|
|
394
|
+
} else if (f.type === 'object') {
|
|
395
|
+
compilePatternsIn(f.fields, [...prefix, k]);
|
|
396
|
+
} else if (f.type === 'array' && f.item.type === 'object') {
|
|
397
|
+
compilePatternsIn(f.item.fields, [...prefix, k]);
|
|
398
|
+
} else if (f.type === 'array' && (f.item.type === 'text' || f.item.type === 'textarea') && f.item.pattern != null) {
|
|
399
|
+
patterns.set([...prefix, k].join('.'), compilePattern(f.item.pattern, f.item.label));
|
|
400
|
+
}
|
|
262
401
|
}
|
|
263
|
-
}
|
|
402
|
+
};
|
|
403
|
+
compilePatternsIn(record, []);
|
|
264
404
|
const validate = (frontmatter: Record<string, unknown>, body: string): ValidationResult => {
|
|
265
405
|
const data: Record<string, unknown> = {};
|
|
266
|
-
const
|
|
406
|
+
const issues: ValidationIssue[] = [];
|
|
267
407
|
for (const [key, field] of Object.entries(record)) {
|
|
268
|
-
validateField(key, field, frontmatter[key],
|
|
408
|
+
const outcome = validateField([key], field, frontmatter[key], patterns);
|
|
409
|
+
issues.push(...outcome.issues);
|
|
410
|
+
if ('value' in outcome) data[key] = outcome.value;
|
|
411
|
+
if (outcome.issues.length === 0 && options.behavior?.[key]?.validate) {
|
|
412
|
+
let message: string | null = null;
|
|
413
|
+
try {
|
|
414
|
+
message = options.behavior[key].validate!('value' in outcome ? outcome.value : undefined, frontmatter);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
console.warn(`cairn: behavior.validate for field "${key}" threw; treating it as valid.`, err);
|
|
417
|
+
}
|
|
418
|
+
if (typeof message === 'string') issues.push({ path: [key], message });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (issues.length > 0) {
|
|
422
|
+
// Back-compat: derive the flat errors map from the located issues, keying each top-level field by
|
|
423
|
+
// the first message that mentions it, so a consumer reading `errors[fieldName]` still works.
|
|
424
|
+
const errors: Record<string, string> = {};
|
|
425
|
+
for (const issue of issues) {
|
|
426
|
+
const top = String(issue.path[0]);
|
|
427
|
+
if (!(top in errors)) errors[top] = issue.message;
|
|
428
|
+
}
|
|
429
|
+
return { ok: false, errors, issues };
|
|
269
430
|
}
|
|
270
|
-
if (Object.keys(errors).length > 0) return { ok: false, errors };
|
|
271
431
|
const refined = options.refine?.(data, body);
|
|
272
|
-
|
|
432
|
+
if (refined && Object.keys(refined).length > 0) {
|
|
433
|
+
return { ok: false, errors: refined, issues: Object.entries(refined).map(([k, m]) => ({ path: [k], message: m })) };
|
|
434
|
+
}
|
|
435
|
+
return { ok: true, data };
|
|
273
436
|
};
|
|
274
437
|
const standard: StandardSchemaV1<StandardInput, Record<string, unknown>>['~standard'] = {
|
|
275
438
|
version: 1,
|
|
@@ -279,10 +442,10 @@ export function fieldset<const R extends Record<string, FieldDescriptor>>(
|
|
|
279
442
|
const result = validate(frontmatter ?? {}, body ?? '');
|
|
280
443
|
return result.ok
|
|
281
444
|
? { value: result.data }
|
|
282
|
-
: { issues: Object.entries(result.errors).map(([key, message]) => ({ message, path: [key] })) };
|
|
445
|
+
: { issues: result.issues ?? Object.entries(result.errors).map(([key, message]) => ({ message, path: [key] })) };
|
|
283
446
|
},
|
|
284
447
|
};
|
|
285
|
-
return { fields: record, behavior: {}, validate, '~standard': standard };
|
|
448
|
+
return { fields: record, behavior: options.behavior ?? {}, validate, '~standard': standard };
|
|
286
449
|
}
|
|
287
450
|
|
|
288
451
|
/**
|