@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.
Files changed (177) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  4. package/dist/components/ComponentForm.svelte +44 -27
  5. package/dist/components/ComponentInsertDialog.svelte +5 -5
  6. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  7. package/dist/components/EditPage.svelte +29 -107
  8. package/dist/components/EditPage.svelte.d.ts +2 -7
  9. package/dist/components/EntryPicker.svelte +117 -0
  10. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  11. package/dist/components/FieldInput.svelte +218 -0
  12. package/dist/components/FieldInput.svelte.d.ts +51 -0
  13. package/dist/components/IconPicker.svelte +2 -2
  14. package/dist/components/IconPicker.svelte.d.ts +2 -0
  15. package/dist/components/LinkPicker.svelte +8 -75
  16. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  17. package/dist/components/MediaHeroField.svelte +8 -5
  18. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  19. package/dist/components/ObjectGroupField.svelte +54 -0
  20. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  21. package/dist/components/ReferenceField.svelte +94 -0
  22. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  23. package/dist/components/RepeatableField.svelte +221 -0
  24. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  25. package/dist/components/cairn-admin.css +4 -0
  26. package/dist/components/preview-doc.js +5 -1
  27. package/dist/components/tidy-validate.js +1 -1
  28. package/dist/content/adapter.js +18 -0
  29. package/dist/content/advisories.d.ts +2 -2
  30. package/dist/content/advisories.js +3 -5
  31. package/dist/content/compose.d.ts +7 -6
  32. package/dist/content/compose.js +26 -20
  33. package/dist/content/concepts.d.ts +21 -15
  34. package/dist/content/concepts.js +55 -32
  35. package/dist/content/field-rules.js +3 -4
  36. package/dist/content/fields.d.ts +49 -1
  37. package/dist/content/fields.js +11 -0
  38. package/dist/content/fieldset.d.ts +31 -10
  39. package/dist/content/fieldset.js +262 -109
  40. package/dist/content/frontmatter-region.d.ts +38 -0
  41. package/dist/content/frontmatter-region.js +75 -0
  42. package/dist/content/frontmatter.d.ts +35 -2
  43. package/dist/content/frontmatter.js +232 -11
  44. package/dist/content/manifest.d.ts +34 -0
  45. package/dist/content/manifest.js +80 -4
  46. package/dist/content/media-refs.d.ts +2 -2
  47. package/dist/content/media-rewrite.js +1 -69
  48. package/dist/content/reference-index.d.ts +56 -0
  49. package/dist/content/reference-index.js +95 -0
  50. package/dist/content/references.d.ts +40 -0
  51. package/dist/content/references.js +0 -0
  52. package/dist/content/standard-schema.d.ts +30 -0
  53. package/dist/content/standard-schema.js +4 -0
  54. package/dist/content/types.d.ts +127 -178
  55. package/dist/delivery/data.d.ts +2 -2
  56. package/dist/delivery/data.js +1 -1
  57. package/dist/delivery/public-routes.d.ts +2 -5
  58. package/dist/delivery/public-routes.js +15 -1
  59. package/dist/delivery/site-descriptors.d.ts +5 -1
  60. package/dist/delivery/site-descriptors.js +8 -3
  61. package/dist/delivery/site-indexes.d.ts +2 -2
  62. package/dist/delivery/site-resolver.d.ts +25 -0
  63. package/dist/delivery/site-resolver.js +49 -0
  64. package/dist/doctor/checks-local.js +6 -11
  65. package/dist/github/backend.d.ts +83 -0
  66. package/dist/github/backend.js +76 -0
  67. package/dist/github/credentials.d.ts +11 -5
  68. package/dist/github/credentials.js +3 -3
  69. package/dist/github/repo.d.ts +8 -19
  70. package/dist/github/repo.js +69 -80
  71. package/dist/github/types.d.ts +1 -1
  72. package/dist/github/types.js +4 -4
  73. package/dist/index.d.ts +16 -12
  74. package/dist/index.js +7 -8
  75. package/dist/islands/index.d.ts +12 -0
  76. package/dist/islands/index.js +83 -0
  77. package/dist/islands/types.d.ts +7 -0
  78. package/dist/islands/types.js +1 -0
  79. package/dist/media/rewrite-plan.d.ts +2 -3
  80. package/dist/media/rewrite-plan.js +2 -3
  81. package/dist/media/usage.d.ts +2 -2
  82. package/dist/media/usage.js +3 -5
  83. package/dist/nav/site-config.d.ts +0 -6
  84. package/dist/nav/site-config.js +6 -4
  85. package/dist/render/component-grammar.js +11 -11
  86. package/dist/render/component-reference.js +5 -3
  87. package/dist/render/component-validate.d.ts +4 -1
  88. package/dist/render/component-validate.js +10 -35
  89. package/dist/render/pipeline.d.ts +0 -6
  90. package/dist/render/pipeline.js +1 -1
  91. package/dist/render/registry.d.ts +34 -34
  92. package/dist/render/registry.js +26 -5
  93. package/dist/render/rehype-dispatch.d.ts +4 -4
  94. package/dist/render/rehype-dispatch.js +36 -11
  95. package/dist/render/remark-directives.js +4 -5
  96. package/dist/render/sanitize-schema.js +1 -1
  97. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  98. package/dist/sveltekit/cairn-admin.js +3 -4
  99. package/dist/sveltekit/content-routes.d.ts +10 -8
  100. package/dist/sveltekit/content-routes.js +269 -181
  101. package/dist/sveltekit/health.d.ts +7 -3
  102. package/dist/sveltekit/health.js +9 -3
  103. package/dist/sveltekit/index.d.ts +1 -1
  104. package/dist/sveltekit/nav-routes.d.ts +6 -5
  105. package/dist/sveltekit/nav-routes.js +22 -20
  106. package/dist/sveltekit/types.d.ts +2 -0
  107. package/dist/vite/index.d.ts +3 -3
  108. package/dist/vite/index.js +17 -8
  109. package/package.json +5 -1
  110. package/src/lib/ambient.ts +7 -0
  111. package/src/lib/components/CairnAdmin.svelte +2 -6
  112. package/src/lib/components/ComponentForm.svelte +48 -27
  113. package/src/lib/components/ComponentInsertDialog.svelte +9 -8
  114. package/src/lib/components/EditPage.svelte +43 -119
  115. package/src/lib/components/EntryPicker.svelte +154 -0
  116. package/src/lib/components/FieldInput.svelte +262 -0
  117. package/src/lib/components/IconPicker.svelte +4 -2
  118. package/src/lib/components/LinkPicker.svelte +10 -81
  119. package/src/lib/components/MediaHeroField.svelte +12 -5
  120. package/src/lib/components/ObjectGroupField.svelte +97 -0
  121. package/src/lib/components/ReferenceField.svelte +126 -0
  122. package/src/lib/components/RepeatableField.svelte +310 -0
  123. package/src/lib/components/preview-doc.ts +5 -1
  124. package/src/lib/components/tidy-validate.ts +1 -1
  125. package/src/lib/content/adapter.ts +21 -0
  126. package/src/lib/content/advisories.ts +4 -7
  127. package/src/lib/content/compose.ts +30 -23
  128. package/src/lib/content/concepts.ts +68 -40
  129. package/src/lib/content/field-rules.ts +3 -4
  130. package/src/lib/content/fields.ts +52 -1
  131. package/src/lib/content/fieldset.ts +291 -128
  132. package/src/lib/content/frontmatter-region.ts +90 -0
  133. package/src/lib/content/frontmatter.ts +231 -15
  134. package/src/lib/content/manifest.ts +101 -4
  135. package/src/lib/content/media-refs.ts +2 -2
  136. package/src/lib/content/media-rewrite.ts +7 -80
  137. package/src/lib/content/reference-index.ts +159 -0
  138. package/src/lib/content/references.ts +0 -0
  139. package/src/lib/content/standard-schema.ts +25 -0
  140. package/src/lib/content/types.ts +128 -195
  141. package/src/lib/delivery/data.ts +2 -2
  142. package/src/lib/delivery/public-routes.ts +17 -3
  143. package/src/lib/delivery/site-descriptors.ts +8 -3
  144. package/src/lib/delivery/site-indexes.ts +2 -2
  145. package/src/lib/delivery/site-resolver.ts +64 -0
  146. package/src/lib/doctor/checks-local.ts +6 -14
  147. package/src/lib/github/backend.ts +161 -0
  148. package/src/lib/github/credentials.ts +10 -7
  149. package/src/lib/github/repo.ts +79 -83
  150. package/src/lib/github/types.ts +5 -5
  151. package/src/lib/index.ts +38 -23
  152. package/src/lib/islands/index.ts +84 -0
  153. package/src/lib/islands/types.ts +11 -0
  154. package/src/lib/media/rewrite-plan.ts +4 -6
  155. package/src/lib/media/usage.ts +4 -7
  156. package/src/lib/nav/site-config.ts +8 -9
  157. package/src/lib/render/component-grammar.ts +10 -10
  158. package/src/lib/render/component-reference.ts +4 -3
  159. package/src/lib/render/component-validate.ts +10 -35
  160. package/src/lib/render/pipeline.ts +1 -7
  161. package/src/lib/render/registry.ts +58 -39
  162. package/src/lib/render/rehype-dispatch.ts +45 -10
  163. package/src/lib/render/remark-directives.ts +4 -5
  164. package/src/lib/render/sanitize-schema.ts +1 -1
  165. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  166. package/src/lib/sveltekit/content-routes.ts +330 -221
  167. package/src/lib/sveltekit/health.ts +13 -6
  168. package/src/lib/sveltekit/index.ts +2 -2
  169. package/src/lib/sveltekit/nav-routes.ts +33 -29
  170. package/src/lib/sveltekit/types.ts +5 -1
  171. package/src/lib/vite/index.ts +20 -11
  172. package/dist/content/schema.d.ts +0 -87
  173. package/dist/content/schema.js +0 -85
  174. package/dist/content/validate.d.ts +0 -17
  175. package/dist/content/validate.js +0 -93
  176. package/src/lib/content/schema.ts +0 -163
  177. package/src/lib/content/validate.ts +0 -90
@@ -1,7 +1,6 @@
1
- // cairn-cms: the shared field constraint rules. Both the v1 `defineFields` validator and the v2
2
- // `fieldset` validator call these pure helpers, so the two validators cannot drift on the
3
- // constraint wording or the first-failing-rule-wins order. No I/O and no clock reads, so the
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
- | ImageField;
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 record becomes a schema
2
- // carrying the descriptors as plain data, a server-derived validator, and the Standard Schema
3
- // conformance property. The validator coerces per type, drops an empty optional field, and returns
4
- // field-keyed errors or normalized data. This is the additive v2 path alongside `defineFields`; the
5
- // inferred-type and default-resolution arms land in later tasks, and the cutover is a later plan.
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
- * The behavior table co-bundled with a fieldset, keyed by field name. It holds function-valued
19
- * behavior a descriptor cannot carry as plain data (a cross-field validator, an array itemLabel).
20
- * Scalars have no behavior, so the table is empty for now and reserved for later co-bundled functions.
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 type BehaviorTable = Record<string, never>;
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: 'select'; options: readonly (infer O extends string)[] }
61
- ? O
62
- : D extends { type: 'multiselect'; options: readonly (infer O extends string)[] }
63
- ? O[]
64
- : D extends { type: 'multiselect' }
65
- ? string[]
66
- : string;
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 Infer<R extends Record<string, FieldDescriptor>> = Prettify<
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> ? Infer<R> : never;
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.toISOString();
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
- // Validate one descriptor against its raw value, writing into `data` or `errors`. Empty or absent is
126
- // "not provided" and is read BEFORE type coercion, uniformly: a required field errors, an optional
127
- // field drops (no key, no error). Only a non-empty value is coerced. boolean is the exception: true
128
- // stores true, anything else omits the key. number relies on the empty-first drop so an empty optional
129
- // number never becomes Number('') === 0.
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
- key: string,
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
- ): void {
138
- // boolean: presence is the value; an unchecked or absent box omits the key (no draft: false noise).
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
- if (value === true) data[key] = true;
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 key (a required empty errors); the array path is the one non-string coercion. A lone
146
- // non-empty scalar (a single tag a YAML scalar carries) coerces to a single-element list, rather than
147
- // dropping to [] and reading as "required" while present. An empty string or a non-string-non-array
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
- errors[key] = `${field.label} is required`;
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
- errors[key] = `${field.label} contains an unknown value: ${unknown}`;
164
- return;
216
+ return { issues: [{ path, message: `${label} contains an unknown value: ${unknown}` }] };
165
217
  }
166
218
  }
167
- if (list.length > 0) data[key] = list;
168
- return;
219
+ return list.length > 0 ? { value: list, issues: [] } : { issues: [] };
169
220
  }
170
221
 
171
- // image: the nested object arm, dropping the key on empty src.
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
- coerceImage(field, key, value, data, errors);
174
- return;
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
- if (field.required) errors[key] = `${field.label} is required`;
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)) errors[key] = `${field.label} must be a number`;
192
- else if (field.integer && !Number.isInteger(n)) errors[key] = `${field.label} must be a whole number`;
193
- else if (field.min != null && n < field.min) errors[key] = `${field.label} must be at least ${field.min}`;
194
- else if (field.max != null && n > field.max) errors[key] = `${field.label} must be at most ${field.max}`;
195
- else data[key] = n;
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)) errors[key] = `${field.label} contains an unknown value: ${text}`;
200
- else data[key] = text;
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)) errors[key] = `${field.label} is not a valid URL`;
205
- else data[key] = text;
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)) errors[key] = `${field.label} is not a valid email address`;
210
- else data[key] = text;
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
- errors[key] = `${field.label} must be a valid date (YYYY-MM-DD)`;
216
- break;
217
- }
218
- const boundsError = dateBoundsError(text, field, field.label);
219
- if (boundsError != null) {
220
- errors[key] = boundsError;
221
- break;
222
- }
223
- data[key] = text;
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, field.label);
232
- if (lengthError != null) {
233
- errors[key] = lengthError;
234
- break;
235
- }
236
- const formatError = patternError(text, patterns.get(key), field.label);
237
- if (formatError != null) {
238
- errors[key] = formatError;
239
- break;
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
- data[key] = text;
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 field name for validateField.
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
- for (const [key, field] of Object.entries(record)) {
260
- if ((field.type === 'text' || field.type === 'textarea') && field.pattern != null) {
261
- patterns.set(key, compilePattern(field.pattern, field.label));
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 errors: Record<string, string> = {};
406
+ const issues: ValidationIssue[] = [];
267
407
  for (const [key, field] of Object.entries(record)) {
268
- validateField(key, field, frontmatter[key], data, errors, patterns);
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
- return refined && Object.keys(refined).length > 0 ? { ok: false, errors: refined } : { ok: true, data };
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
  /**