@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.
Files changed (196) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/auth/types.d.ts +7 -0
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  5. package/dist/components/ComponentForm.svelte +44 -27
  6. package/dist/components/ComponentInsertDialog.svelte +22 -11
  7. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  8. package/dist/components/ConceptList.svelte +25 -4
  9. package/dist/components/EditPage.svelte +29 -107
  10. package/dist/components/EditPage.svelte.d.ts +2 -7
  11. package/dist/components/EntryPicker.svelte +117 -0
  12. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  13. package/dist/components/FieldInput.svelte +218 -0
  14. package/dist/components/FieldInput.svelte.d.ts +51 -0
  15. package/dist/components/IconPicker.svelte +2 -2
  16. package/dist/components/IconPicker.svelte.d.ts +2 -0
  17. package/dist/components/LinkPicker.svelte +8 -75
  18. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  19. package/dist/components/MediaHeroField.svelte +8 -5
  20. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  21. package/dist/components/ObjectGroupField.svelte +54 -0
  22. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  23. package/dist/components/ReferenceField.svelte +94 -0
  24. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  25. package/dist/components/RepeatableField.svelte +221 -0
  26. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  27. package/dist/components/cairn-admin.css +179 -2
  28. package/dist/components/preview-doc.js +5 -1
  29. package/dist/components/tidy-validate.js +1 -1
  30. package/dist/content/adapter.js +18 -0
  31. package/dist/content/advisories.d.ts +2 -2
  32. package/dist/content/advisories.js +3 -5
  33. package/dist/content/compose.d.ts +7 -6
  34. package/dist/content/compose.js +26 -20
  35. package/dist/content/concepts.d.ts +21 -15
  36. package/dist/content/concepts.js +55 -32
  37. package/dist/content/field-rules.d.ts +15 -0
  38. package/dist/content/field-rules.js +38 -0
  39. package/dist/content/fields.d.ts +169 -0
  40. package/dist/content/fields.js +41 -0
  41. package/dist/content/fieldset.d.ts +107 -0
  42. package/dist/content/fieldset.js +386 -0
  43. package/dist/content/frontmatter-region.d.ts +38 -0
  44. package/dist/content/frontmatter-region.js +75 -0
  45. package/dist/content/frontmatter.d.ts +35 -2
  46. package/dist/content/frontmatter.js +232 -11
  47. package/dist/content/manifest.d.ts +34 -0
  48. package/dist/content/manifest.js +80 -4
  49. package/dist/content/media-refs.d.ts +2 -2
  50. package/dist/content/media-rewrite.js +1 -69
  51. package/dist/content/reference-index.d.ts +56 -0
  52. package/dist/content/reference-index.js +95 -0
  53. package/dist/content/references.d.ts +40 -0
  54. package/dist/content/references.js +0 -0
  55. package/dist/content/standard-schema.d.ts +30 -0
  56. package/dist/content/standard-schema.js +4 -0
  57. package/dist/content/types.d.ts +127 -178
  58. package/dist/delivery/data.d.ts +2 -2
  59. package/dist/delivery/data.js +1 -1
  60. package/dist/delivery/public-routes.d.ts +10 -5
  61. package/dist/delivery/public-routes.js +25 -2
  62. package/dist/delivery/site-descriptors.d.ts +5 -1
  63. package/dist/delivery/site-descriptors.js +8 -3
  64. package/dist/delivery/site-indexes.d.ts +2 -2
  65. package/dist/delivery/site-resolver.d.ts +25 -0
  66. package/dist/delivery/site-resolver.js +49 -0
  67. package/dist/doctor/checks-local.js +6 -11
  68. package/dist/github/backend.d.ts +83 -0
  69. package/dist/github/backend.js +76 -0
  70. package/dist/github/credentials.d.ts +11 -5
  71. package/dist/github/credentials.js +3 -3
  72. package/dist/github/repo.d.ts +8 -19
  73. package/dist/github/repo.js +69 -80
  74. package/dist/github/types.d.ts +1 -1
  75. package/dist/github/types.js +4 -4
  76. package/dist/index.d.ts +18 -10
  77. package/dist/index.js +9 -5
  78. package/dist/islands/index.d.ts +12 -0
  79. package/dist/islands/index.js +83 -0
  80. package/dist/islands/types.d.ts +7 -0
  81. package/dist/islands/types.js +1 -0
  82. package/dist/log/events.d.ts +1 -1
  83. package/dist/media/index.d.ts +1 -1
  84. package/dist/media/index.js +1 -1
  85. package/dist/media/manifest.d.ts +11 -0
  86. package/dist/media/manifest.js +13 -0
  87. package/dist/media/rewrite-plan.d.ts +2 -3
  88. package/dist/media/rewrite-plan.js +2 -3
  89. package/dist/media/usage.d.ts +2 -2
  90. package/dist/media/usage.js +3 -5
  91. package/dist/nav/site-config.d.ts +0 -6
  92. package/dist/nav/site-config.js +6 -4
  93. package/dist/render/component-grammar.js +11 -11
  94. package/dist/render/component-reference.js +5 -3
  95. package/dist/render/component-validate.d.ts +4 -1
  96. package/dist/render/component-validate.js +10 -35
  97. package/dist/render/highlight.d.ts +9 -0
  98. package/dist/render/highlight.js +206 -0
  99. package/dist/render/pipeline.d.ts +0 -6
  100. package/dist/render/pipeline.js +13 -2
  101. package/dist/render/registry.d.ts +44 -36
  102. package/dist/render/registry.js +47 -6
  103. package/dist/render/rehype-dispatch.d.ts +6 -10
  104. package/dist/render/rehype-dispatch.js +38 -17
  105. package/dist/render/remark-directives.js +4 -5
  106. package/dist/render/sanitize-schema.d.ts +10 -0
  107. package/dist/render/sanitize-schema.js +30 -1
  108. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  109. package/dist/sveltekit/cairn-admin.js +3 -4
  110. package/dist/sveltekit/content-routes.d.ts +10 -8
  111. package/dist/sveltekit/content-routes.js +269 -181
  112. package/dist/sveltekit/guard.js +10 -0
  113. package/dist/sveltekit/health.d.ts +7 -3
  114. package/dist/sveltekit/health.js +9 -3
  115. package/dist/sveltekit/index.d.ts +1 -1
  116. package/dist/sveltekit/nav-routes.d.ts +6 -5
  117. package/dist/sveltekit/nav-routes.js +22 -20
  118. package/dist/sveltekit/types.d.ts +2 -0
  119. package/dist/vite/index.d.ts +3 -3
  120. package/dist/vite/index.js +17 -8
  121. package/package.json +17 -2
  122. package/src/lib/ambient.ts +7 -0
  123. package/src/lib/auth/types.ts +7 -0
  124. package/src/lib/components/CairnAdmin.svelte +2 -6
  125. package/src/lib/components/ComponentForm.svelte +48 -27
  126. package/src/lib/components/ComponentInsertDialog.svelte +26 -14
  127. package/src/lib/components/ConceptList.svelte +41 -4
  128. package/src/lib/components/EditPage.svelte +43 -119
  129. package/src/lib/components/EntryPicker.svelte +154 -0
  130. package/src/lib/components/FieldInput.svelte +262 -0
  131. package/src/lib/components/IconPicker.svelte +4 -2
  132. package/src/lib/components/LinkPicker.svelte +10 -81
  133. package/src/lib/components/MediaHeroField.svelte +12 -5
  134. package/src/lib/components/ObjectGroupField.svelte +97 -0
  135. package/src/lib/components/ReferenceField.svelte +126 -0
  136. package/src/lib/components/RepeatableField.svelte +310 -0
  137. package/src/lib/components/preview-doc.ts +5 -1
  138. package/src/lib/components/tidy-validate.ts +1 -1
  139. package/src/lib/content/adapter.ts +21 -0
  140. package/src/lib/content/advisories.ts +4 -7
  141. package/src/lib/content/compose.ts +30 -23
  142. package/src/lib/content/concepts.ts +68 -40
  143. package/src/lib/content/field-rules.ts +39 -0
  144. package/src/lib/content/fields.ts +178 -0
  145. package/src/lib/content/fieldset.ts +470 -0
  146. package/src/lib/content/frontmatter-region.ts +90 -0
  147. package/src/lib/content/frontmatter.ts +231 -15
  148. package/src/lib/content/manifest.ts +101 -4
  149. package/src/lib/content/media-refs.ts +2 -2
  150. package/src/lib/content/media-rewrite.ts +7 -80
  151. package/src/lib/content/reference-index.ts +159 -0
  152. package/src/lib/content/references.ts +0 -0
  153. package/src/lib/content/standard-schema.ts +25 -0
  154. package/src/lib/content/types.ts +128 -195
  155. package/src/lib/delivery/data.ts +2 -2
  156. package/src/lib/delivery/public-routes.ts +36 -4
  157. package/src/lib/delivery/site-descriptors.ts +8 -3
  158. package/src/lib/delivery/site-indexes.ts +2 -2
  159. package/src/lib/delivery/site-resolver.ts +64 -0
  160. package/src/lib/doctor/checks-local.ts +6 -14
  161. package/src/lib/github/backend.ts +161 -0
  162. package/src/lib/github/credentials.ts +10 -7
  163. package/src/lib/github/repo.ts +79 -83
  164. package/src/lib/github/types.ts +5 -5
  165. package/src/lib/index.ts +40 -18
  166. package/src/lib/islands/index.ts +84 -0
  167. package/src/lib/islands/types.ts +11 -0
  168. package/src/lib/log/events.ts +1 -0
  169. package/src/lib/media/index.ts +1 -0
  170. package/src/lib/media/manifest.ts +14 -0
  171. package/src/lib/media/rewrite-plan.ts +4 -6
  172. package/src/lib/media/usage.ts +4 -7
  173. package/src/lib/nav/site-config.ts +8 -9
  174. package/src/lib/render/component-grammar.ts +10 -10
  175. package/src/lib/render/component-reference.ts +4 -3
  176. package/src/lib/render/component-validate.ts +10 -35
  177. package/src/lib/render/highlight.ts +259 -0
  178. package/src/lib/render/pipeline.ts +13 -8
  179. package/src/lib/render/registry.ts +88 -42
  180. package/src/lib/render/rehype-dispatch.ts +47 -16
  181. package/src/lib/render/remark-directives.ts +4 -5
  182. package/src/lib/render/sanitize-schema.ts +32 -1
  183. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  184. package/src/lib/sveltekit/content-routes.ts +330 -221
  185. package/src/lib/sveltekit/guard.ts +15 -0
  186. package/src/lib/sveltekit/health.ts +13 -6
  187. package/src/lib/sveltekit/index.ts +2 -2
  188. package/src/lib/sveltekit/nav-routes.ts +33 -29
  189. package/src/lib/sveltekit/types.ts +5 -1
  190. package/src/lib/vite/index.ts +20 -11
  191. package/dist/content/schema.d.ts +0 -87
  192. package/dist/content/schema.js +0 -89
  193. package/dist/content/validate.d.ts +0 -17
  194. package/dist/content/validate.js +0 -93
  195. package/src/lib/content/schema.ts +0 -167
  196. package/src/lib/content/validate.ts +0 -90
@@ -0,0 +1,386 @@
1
+ import { datetimeInputValue, dateInputValue, isCalendarDate, referenceIdsFromValue } from './frontmatter.js';
2
+ import { compilePattern, dateBoundsError, patternError, stringLengthError } from './field-rules.js';
3
+ import { isValidId } from './ids.js';
4
+ /** Accept any URL using http or https with a non-empty rest, mirroring the conservative form check. */
5
+ const URL_RE = /^https?:\/\/\S+$/;
6
+ /** Accept a single address conservatively: exactly one at-sign and a dotted domain, nothing more. */
7
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
8
+ // Coerce a raw value to the trimmed string the empty check and constraints run on. A parsed value may
9
+ // arrive from parseMarkdown, not only a form string: a Date on a date or datetime field, a JS number on
10
+ // a number field. A finite 0 coerces to '0', never read as empty, since 0 is a real number a YAML scalar
11
+ // carries; a NaN or non-finite number stays '' and routes to the number error in validateField.
12
+ function coerceToText(type, value) {
13
+ if (type === 'date' && value instanceof Date)
14
+ return dateInputValue(value);
15
+ if (type === 'datetime' && value instanceof Date)
16
+ return datetimeInputValue(value);
17
+ if (type === 'number' && typeof value === 'number' && Number.isFinite(value))
18
+ return String(value);
19
+ if (typeof value === 'string')
20
+ return value.trim();
21
+ return '';
22
+ }
23
+ // Build the structural key for a path by dropping numeric (row-index) segments, so a nested text
24
+ // field's compiled pattern is found regardless of which row it sits in: ['faq', 2, 'code'] -> 'faq.code'.
25
+ function structuralKey(path) {
26
+ return path.filter((seg) => typeof seg === 'string').join('.');
27
+ }
28
+ // Validate one descriptor against its raw value and return its outcome. Empty or absent is
29
+ // "not provided" and is read BEFORE type coercion, uniformly: a required field returns an issue, an
30
+ // optional field drops (no value, no issue). Only a non-empty value is coerced. boolean is the
31
+ // exception: true stores true, anything else omits the value. number relies on the empty-first drop so
32
+ // an empty optional number never becomes Number('') === 0. A container (object, array) recurses one
33
+ // level, appending the leaf key or element index to `path` for each nested issue.
34
+ function validateField(path, field, value, patterns) {
35
+ const label = field.label ?? '';
36
+ // object: validate each leaf one level down, assembling a nested object value and concatenating
37
+ // issues with the leaf key appended to the path. An empty (all-leaves-dropped) object omits the
38
+ // value; a required empty object is an error on the object's own path.
39
+ if (field.type === 'object') {
40
+ const obj = {};
41
+ const issues = [];
42
+ const raw = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
43
+ for (const [leafKey, leaf] of Object.entries(field.fields)) {
44
+ const outcome = validateField([...path, leafKey], leaf, raw[leafKey], patterns);
45
+ issues.push(...outcome.issues);
46
+ if ('value' in outcome)
47
+ obj[leafKey] = outcome.value;
48
+ }
49
+ if (issues.length > 0)
50
+ return { issues };
51
+ if (Object.keys(obj).length === 0) {
52
+ return field.required ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
53
+ }
54
+ return { value: obj, issues };
55
+ }
56
+ // array: a reference item keeps the shipped id-list path; any other item recurses per element with
57
+ // the element index appended to the path. A required empty list errors on the array's own path.
58
+ if (field.type === 'array') {
59
+ if (field.item.type === 'reference') {
60
+ // array(reference): coerceToText returns '' for an array, so the empty-first drop below would
61
+ // silently lose an optional list or falsely error a required one. The canonicalizer coerces a
62
+ // lone scalar to one element and a Date element to its id. Each element must pass isValidId (the
63
+ // item's reference rule this phase); a required empty list errors; the value is set only when the
64
+ // list is non-empty.
65
+ const list = referenceIdsFromValue(value);
66
+ if (field.required && list.length === 0)
67
+ return { issues: [{ path, message: `${label} is required` }] };
68
+ const invalid = list.find((id) => !isValidId(id));
69
+ if (invalid !== undefined)
70
+ return { issues: [{ path, message: `${label} is not a valid reference` }] };
71
+ return list.length > 0 ? { value: list, issues: [] } : { issues: [] };
72
+ }
73
+ const elements = Array.isArray(value) ? value : [];
74
+ const out = [];
75
+ const issues = [];
76
+ elements.forEach((element, i) => {
77
+ const outcome = validateField([...path, i], field.item, element, patterns);
78
+ issues.push(...outcome.issues);
79
+ if ('value' in outcome)
80
+ out.push(outcome.value);
81
+ });
82
+ if (issues.length > 0)
83
+ return { issues };
84
+ if (out.length === 0) {
85
+ return field.required ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
86
+ }
87
+ return { value: out, issues };
88
+ }
89
+ // boolean: presence is the value; an unchecked or absent box omits the value (no draft: false noise).
90
+ if (field.type === 'boolean') {
91
+ return value === true ? { value: true, issues: [] } : { issues: [] };
92
+ }
93
+ // multiselect: a string array; drop empties, reject an unknown value when options is closed. An empty
94
+ // list omits the value (a required empty errors); the array path is the one non-string coercion. A
95
+ // lone non-empty scalar (a single tag a YAML scalar carries) coerces to a single-element list, rather
96
+ // than dropping to [] and reading as "required" while present. An empty string or a
97
+ // non-string-non-array stays the empty list.
98
+ if (field.type === 'multiselect') {
99
+ let raw;
100
+ if (Array.isArray(value))
101
+ raw = value.map(String);
102
+ else if (typeof value === 'string' && value.trim() !== '')
103
+ raw = [value.trim()];
104
+ else
105
+ raw = [];
106
+ const list = raw.map((v) => v.trim()).filter((v) => v !== '');
107
+ if (field.required && list.length === 0) {
108
+ return { issues: [{ path, message: `${label} is required` }] };
109
+ }
110
+ const { options } = field;
111
+ if (options) {
112
+ const unknown = list.find((v) => !options.includes(v));
113
+ if (unknown !== undefined) {
114
+ return { issues: [{ path, message: `${label} contains an unknown value: ${unknown}` }] };
115
+ }
116
+ }
117
+ return list.length > 0 ? { value: list, issues: [] } : { issues: [] };
118
+ }
119
+ // image: the nested object arm, dropping the value on empty src. Default a missing alt to empty (alt
120
+ // is debt, never a save block), trim and drop a blank caption, keep decorative only when an explicit
121
+ // true. A required image with an empty src is the one error this arm raises.
122
+ if (field.type === 'image') {
123
+ let src = '';
124
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
125
+ const obj = value;
126
+ src = typeof obj.src === 'string' ? obj.src.trim() : '';
127
+ if (src !== '') {
128
+ const normalized = {
129
+ src,
130
+ alt: typeof obj.alt === 'string' ? obj.alt : '',
131
+ };
132
+ const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
133
+ if (caption !== '')
134
+ normalized.caption = caption;
135
+ if (obj.decorative === true)
136
+ normalized.decorative = true;
137
+ return { value: normalized, issues: [] };
138
+ }
139
+ }
140
+ return field.required && src === '' ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
141
+ }
142
+ // Every other type is "not provided when empty" first, then coerced. `coerceToText` turns a parsed
143
+ // value into its string form BEFORE the empty check, so a real parsed value (a Date on a date or
144
+ // datetime field, a number on a number field) is not read as empty.
145
+ const text = coerceToText(field.type, value);
146
+ if (text === '') {
147
+ return field.required ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
148
+ }
149
+ const key = structuralKey(path);
150
+ switch (field.type) {
151
+ case 'number': {
152
+ const n = Number(text);
153
+ // Reject NaN and the non-finite values Number() yields for "Infinity"/"1e400", which an
154
+ // isNaN check alone would pass through and commit as a YAML .inf scalar.
155
+ if (!Number.isFinite(n))
156
+ return { issues: [{ path, message: `${label} must be a number` }] };
157
+ if (field.integer && !Number.isInteger(n))
158
+ return { issues: [{ path, message: `${label} must be a whole number` }] };
159
+ if (field.min != null && n < field.min)
160
+ return { issues: [{ path, message: `${label} must be at least ${field.min}` }] };
161
+ if (field.max != null && n > field.max)
162
+ return { issues: [{ path, message: `${label} must be at most ${field.max}` }] };
163
+ return { value: n, issues: [] };
164
+ }
165
+ case 'select': {
166
+ if (!field.options.includes(text))
167
+ return { issues: [{ path, message: `${label} contains an unknown value: ${text}` }] };
168
+ return { value: text, issues: [] };
169
+ }
170
+ case 'url': {
171
+ if (!URL_RE.test(text))
172
+ return { issues: [{ path, message: `${label} is not a valid URL` }] };
173
+ return { value: text, issues: [] };
174
+ }
175
+ case 'email': {
176
+ if (!EMAIL_RE.test(text))
177
+ return { issues: [{ path, message: `${label} is not a valid email address` }] };
178
+ return { value: text, issues: [] };
179
+ }
180
+ case 'date': {
181
+ if (!isCalendarDate(text))
182
+ return { issues: [{ path, message: `${label} must be a valid date (YYYY-MM-DD)` }] };
183
+ const boundsError = dateBoundsError(text, field, label);
184
+ if (boundsError != null)
185
+ return { issues: [{ path, message: boundsError }] };
186
+ return { value: text, issues: [] };
187
+ }
188
+ case 'reference': {
189
+ // A scalar edge: the empty-first drop above already handled an absent optional, so a non-empty
190
+ // value must be a valid id. An invalid token is a corrupted edge, not a coercible value.
191
+ if (!isValidId(text))
192
+ return { issues: [{ path, message: `${label} is not a valid reference` }] };
193
+ return { value: text, issues: [] };
194
+ }
195
+ default: {
196
+ // text, textarea, datetime: a trimmed non-empty string. text and textarea also enforce the
197
+ // string-length and pattern constraints (v1 parity); datetime stays a plain string for now,
198
+ // since its bounds are out of scope this pass and v1 has no datetime equivalent to match.
199
+ if (field.type === 'text' || field.type === 'textarea') {
200
+ const lengthError = stringLengthError(text, field, label);
201
+ if (lengthError != null)
202
+ return { issues: [{ path, message: lengthError }] };
203
+ const formatError = patternError(text, patterns.get(key), label);
204
+ if (formatError != null)
205
+ return { issues: [{ path, message: formatError }] };
206
+ }
207
+ return { value: text, issues: [] };
208
+ }
209
+ }
210
+ }
211
+ // At most one image field may feed the social card, so the og:image is unambiguous. A v2 fieldset
212
+ // marks that field with an explicit `seo: true`; there is no field-name default, since the record key
213
+ // is arbitrary. Two seo images is a site config error, so fail loudly at declaration (v1 parity).
214
+ // The delivery seo reader resolves the social card off a hardcoded top-level key list, so a nested
215
+ // seo image cannot resolve at delivery; this phase forbids seo: true inside any container and defers
216
+ // nested seo to the pass that generalizes delivery seo resolution.
217
+ function checkSeoImageFields(record) {
218
+ const seo = [];
219
+ for (const [key, field] of Object.entries(record)) {
220
+ if (field.type === 'image' && field.seo === true)
221
+ seo.push(`"${key}"`);
222
+ else if (field.type === 'object') {
223
+ for (const [leafKey, leaf] of Object.entries(field.fields)) {
224
+ if (leaf.type === 'image' && leaf.seo === true) {
225
+ 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.`);
226
+ }
227
+ }
228
+ }
229
+ else if (field.type === 'array') {
230
+ const item = field.item;
231
+ const nested = (item.type === 'image' && item.seo === true)
232
+ || (item.type === 'object' && Object.values(item.fields).some((l) => l.type === 'image' && l.seo === true));
233
+ if (nested) {
234
+ 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.`);
235
+ }
236
+ }
237
+ }
238
+ if (seo.length > 1) {
239
+ 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.`);
240
+ }
241
+ }
242
+ // A leaf is any non-container descriptor. A container (object, array) may hold leaves one level deep only.
243
+ function isLeaf(field) {
244
+ return field.type !== 'object' && field.type !== 'array';
245
+ }
246
+ // Enforce the one-level nesting cap, the no-reference-in-object deferral, and the no-dot-in-key rule, all
247
+ // loudly at declaration. A deeper nesting, a nested reference, or a dotted key would otherwise mis-save or
248
+ // mis-decode at the edge, so fail here.
249
+ function checkContainerNesting(record) {
250
+ const checkKey = (k, where) => {
251
+ if (k.includes('.'))
252
+ throw new Error(`cairn: ${where} "${k}" must not contain a dot; field keys address the nested form by dotted path.`);
253
+ };
254
+ const checkObjectLeaves = (fieldsRecord, where) => {
255
+ for (const [k, leaf] of Object.entries(fieldsRecord)) {
256
+ checkKey(k, where);
257
+ if (!isLeaf(leaf)) {
258
+ throw new Error(`cairn: ${where} "${k}" must be a leaf field; containers nest one level only.`);
259
+ }
260
+ if (leaf.type === 'reference') {
261
+ 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).`);
262
+ }
263
+ }
264
+ };
265
+ for (const [key, field] of Object.entries(record)) {
266
+ checkKey(key, 'the field');
267
+ if (field.type === 'object') {
268
+ checkObjectLeaves(field.fields, `the object field "${key}" sub-field`);
269
+ }
270
+ else if (field.type === 'array') {
271
+ const item = field.item;
272
+ if (item.type === 'object') {
273
+ checkObjectLeaves(item.fields, `the array field "${key}" row sub-field`);
274
+ }
275
+ else if (!isLeaf(item)) {
276
+ throw new Error(`cairn: the array field "${key}" item must be a leaf or a flat object; an array of arrays is not allowed.`);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ /**
282
+ * Build a fieldset from a key-to-descriptor record. The returned schema carries the descriptors, a
283
+ * server-derived validator that coerces per type and returns field-keyed errors or normalized data,
284
+ * and the Standard Schema conformance property whose issues map each error to a single-segment path.
285
+ */
286
+ export function fieldset(record, options = {}) {
287
+ checkSeoImageFields(record);
288
+ checkContainerNesting(record);
289
+ for (const key of Object.keys(options.behavior ?? {})) {
290
+ if (!(key in record))
291
+ throw new Error(`cairn: behavior names "${key}", which is not a declared field.`);
292
+ }
293
+ // Compile each text/textarea pattern once at construction, so a malformed pattern fails loudly here
294
+ // (mirroring v1's compilePatterns) rather than on every save. Keyed by the structural path
295
+ // ('faq.code', 'address.zip') so a nested leaf's compiled pattern is found regardless of row index,
296
+ // recursing one level into an object and an array(object).
297
+ const patterns = new Map();
298
+ const compilePatternsIn = (rec, prefix) => {
299
+ for (const [k, f] of Object.entries(rec)) {
300
+ if ((f.type === 'text' || f.type === 'textarea') && f.pattern != null) {
301
+ patterns.set([...prefix, k].join('.'), compilePattern(f.pattern, f.label));
302
+ }
303
+ else if (f.type === 'object') {
304
+ compilePatternsIn(f.fields, [...prefix, k]);
305
+ }
306
+ else if (f.type === 'array' && f.item.type === 'object') {
307
+ compilePatternsIn(f.item.fields, [...prefix, k]);
308
+ }
309
+ else if (f.type === 'array' && (f.item.type === 'text' || f.item.type === 'textarea') && f.item.pattern != null) {
310
+ patterns.set([...prefix, k].join('.'), compilePattern(f.item.pattern, f.item.label));
311
+ }
312
+ }
313
+ };
314
+ compilePatternsIn(record, []);
315
+ const validate = (frontmatter, body) => {
316
+ const data = {};
317
+ const issues = [];
318
+ for (const [key, field] of Object.entries(record)) {
319
+ const outcome = validateField([key], field, frontmatter[key], patterns);
320
+ issues.push(...outcome.issues);
321
+ if ('value' in outcome)
322
+ data[key] = outcome.value;
323
+ if (outcome.issues.length === 0 && options.behavior?.[key]?.validate) {
324
+ let message = null;
325
+ try {
326
+ message = options.behavior[key].validate('value' in outcome ? outcome.value : undefined, frontmatter);
327
+ }
328
+ catch (err) {
329
+ console.warn(`cairn: behavior.validate for field "${key}" threw; treating it as valid.`, err);
330
+ }
331
+ if (typeof message === 'string')
332
+ issues.push({ path: [key], message });
333
+ }
334
+ }
335
+ if (issues.length > 0) {
336
+ // Back-compat: derive the flat errors map from the located issues, keying each top-level field by
337
+ // the first message that mentions it, so a consumer reading `errors[fieldName]` still works.
338
+ const errors = {};
339
+ for (const issue of issues) {
340
+ const top = String(issue.path[0]);
341
+ if (!(top in errors))
342
+ errors[top] = issue.message;
343
+ }
344
+ return { ok: false, errors, issues };
345
+ }
346
+ const refined = options.refine?.(data, body);
347
+ if (refined && Object.keys(refined).length > 0) {
348
+ return { ok: false, errors: refined, issues: Object.entries(refined).map(([k, m]) => ({ path: [k], message: m })) };
349
+ }
350
+ return { ok: true, data };
351
+ };
352
+ const standard = {
353
+ version: 1,
354
+ vendor: 'cairn',
355
+ validate: (value) => {
356
+ const { frontmatter = {}, body = '' } = (value ?? {});
357
+ const result = validate(frontmatter ?? {}, body ?? '');
358
+ return result.ok
359
+ ? { value: result.data }
360
+ : { issues: result.issues ?? Object.entries(result.errors).map(([key, message]) => ({ message, path: [key] })) };
361
+ },
362
+ };
363
+ return { fields: record, behavior: options.behavior ?? {}, validate, '~standard': standard };
364
+ }
365
+ /**
366
+ * Resolve each descriptor's `default` to a form-initial value, so a fresh entry opens prefilled. The
367
+ * `'today'` sentinel on a date field resolves through the passed `now` to its `YYYY-MM-DD` form; an
368
+ * empty-string or `false` default is omitted, so an untouched field commits no key (the
369
+ * minimal-frontmatter invariant). With no `now`, a `'today'` default is omitted rather than read off
370
+ * a real clock, since library code must stay deterministic and Workers-safe.
371
+ */
372
+ export function initialValues(fieldset, now) {
373
+ const values = {};
374
+ for (const [key, field] of Object.entries(fieldset.fields)) {
375
+ const value = field.default;
376
+ if (value === undefined || value === '' || value === false)
377
+ continue;
378
+ if (field.type === 'date' && value === 'today') {
379
+ if (now)
380
+ values[key] = now.toISOString().slice(0, 10);
381
+ continue;
382
+ }
383
+ values[key] = value;
384
+ }
385
+ return values;
386
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The split of fmBlock into its lines, each with its block-relative start and end offsets (the end
3
+ * is the index of the trailing newline, or the block length for the last line). Block offsets are
4
+ * already absolute since the frontmatter leads the document.
5
+ */
6
+ export interface FmLine {
7
+ start: number;
8
+ end: number;
9
+ }
10
+ /**
11
+ * Split a leading frontmatter block off the markdown. `fmBlock` is the `---` fenced block including
12
+ * both fences and the trailing newline (empty when there is none); `body` is everything after it.
13
+ * The block leads the document, so a frontmatter offset is already absolute and a body offset needs
14
+ * `fmBlock.length` added. Shared by every arm so they agree on the boundary.
15
+ */
16
+ export declare function splitFrontmatter(markdown: string): {
17
+ fmBlock: string;
18
+ body: string;
19
+ };
20
+ /**
21
+ * Split fmBlock into lines once, so the locator helpers walk a shared structure instead of
22
+ * re-scanning the block per call.
23
+ */
24
+ export declare function fmLines(fmBlock: string): FmLine[];
25
+ /**
26
+ * The inclusive line-index range `[lo, hi]` of the block-style mapping a top-level key opens: the
27
+ * line `^<key>:` at indent 0 through the last line before the next top-level key (or the document
28
+ * end). A flow-style value (`key: { ... }` all on one line) yields a single-line range. Returns null
29
+ * when the key has no top-level line, which a malformed or non-canonical block can cause. Scoping the
30
+ * per-key search to this range is what lets two image fields that share one hash, or an image field
31
+ * whose hash also appears in a sibling text value, resolve to distinct, correct spans.
32
+ */
33
+ export declare function frontmatterKeyRange(lines: FmLine[], fmBlock: string, key: string): [number, number] | null;
34
+ /**
35
+ * Escape a literal string for safe interpolation into a RegExp source. A key name or an indent is
36
+ * matched literally, so its characters must not act as metacharacters.
37
+ */
38
+ export declare function escapeForRegExp(literal: string): string;
@@ -0,0 +1,75 @@
1
+ // cairn-cms: the shared frontmatter-region helpers. A byte-preserving rewriter (media-rewrite's
2
+ // `repointMediaRef`/`fillAltForHash`, references' `rewriteFrontmatterReference`) splices a
3
+ // frontmatter value by source offset rather than round-tripping through gray-matter (which reformats
4
+ // YAML and is not byte stable). These helpers locate the `---` fenced block, split it into lines with
5
+ // absolute offsets, and find a top-level key's inclusive line range, so every such rewriter agrees on
6
+ // the boundary, the CRLF handling, and the colon-anchored key scan.
7
+ /**
8
+ * Split a leading frontmatter block off the markdown. `fmBlock` is the `---` fenced block including
9
+ * both fences and the trailing newline (empty when there is none); `body` is everything after it.
10
+ * The block leads the document, so a frontmatter offset is already absolute and a body offset needs
11
+ * `fmBlock.length` added. Shared by every arm so they agree on the boundary.
12
+ */
13
+ export function splitFrontmatter(markdown) {
14
+ const m = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
15
+ const fmBlock = m ? m[0] : '';
16
+ return { fmBlock, body: markdown.slice(fmBlock.length) };
17
+ }
18
+ /**
19
+ * Split fmBlock into lines once, so the locator helpers walk a shared structure instead of
20
+ * re-scanning the block per call.
21
+ */
22
+ export function fmLines(fmBlock) {
23
+ const lines = [];
24
+ let pos = 0;
25
+ while (pos <= fmBlock.length) {
26
+ const nl = fmBlock.indexOf('\n', pos);
27
+ const end = nl === -1 ? fmBlock.length : nl;
28
+ lines.push({ start: pos, end });
29
+ if (nl === -1)
30
+ break;
31
+ pos = nl + 1;
32
+ }
33
+ return lines;
34
+ }
35
+ /**
36
+ * The inclusive line-index range `[lo, hi]` of the block-style mapping a top-level key opens: the
37
+ * line `^<key>:` at indent 0 through the last line before the next top-level key (or the document
38
+ * end). A flow-style value (`key: { ... }` all on one line) yields a single-line range. Returns null
39
+ * when the key has no top-level line, which a malformed or non-canonical block can cause. Scoping the
40
+ * per-key search to this range is what lets two image fields that share one hash, or an image field
41
+ * whose hash also appears in a sibling text value, resolve to distinct, correct spans.
42
+ */
43
+ export function frontmatterKeyRange(lines, fmBlock, key) {
44
+ const opener = new RegExp(`^${escapeForRegExp(key)}:`);
45
+ const topLevelKey = /^[^\s#][^:]*:/;
46
+ const isBoundary = (i) => {
47
+ const text = fmBlock.slice(lines[i].start, lines[i].end);
48
+ // A new top-level key or the closing `---` fence ends the current key's block.
49
+ return topLevelKey.test(text) || text === '---';
50
+ };
51
+ let lo = -1;
52
+ for (let i = 1; i < lines.length - 1; i += 1) {
53
+ // Skip the leading `---` fence (line 0) and the trailing empty line after the closing fence.
54
+ if (opener.test(fmBlock.slice(lines[i].start, lines[i].end))) {
55
+ lo = i;
56
+ break;
57
+ }
58
+ }
59
+ if (lo === -1)
60
+ return null;
61
+ let hi = lo;
62
+ for (let i = lo + 1; i < lines.length - 1; i += 1) {
63
+ if (isBoundary(i))
64
+ break;
65
+ hi = i;
66
+ }
67
+ return [lo, hi];
68
+ }
69
+ /**
70
+ * Escape a literal string for safe interpolation into a RegExp source. A key name or an indent is
71
+ * matched literally, so its characters must not act as metacharacters.
72
+ */
73
+ export function escapeForRegExp(literal) {
74
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
75
+ }
@@ -1,6 +1,16 @@
1
- import type { FrontmatterField } from './types.js';
1
+ import type { NamedField } from './types.js';
2
+ /**
3
+ * True when a multiselect field is a closed checkbox group: it declares an options vocabulary and is
4
+ * not author-extendable. The save decoder and the editor render arm both call this, so the
5
+ * closed-versus-open multiselect decision can never drift between decode and display.
6
+ */
7
+ export declare function isClosedMultiselect(field: {
8
+ type: string;
9
+ options?: readonly string[];
10
+ creatable?: boolean;
11
+ }): boolean;
2
12
  /** Decode submitted form data into raw frontmatter, one rule per field type. */
3
- export declare function frontmatterFromForm(fields: FrontmatterField[], form: FormData): Record<string, unknown>;
13
+ export declare function frontmatterFromForm(fields: NamedField[], form: FormData): Record<string, unknown>;
4
14
  /**
5
15
  * Coerce a frontmatter date value to the `YYYY-MM-DD` an `<input type="date">` wants.
6
16
  * gray-matter parses an unquoted YAML date into a JS Date, so a string-only read would
@@ -8,6 +18,14 @@ export declare function frontmatterFromForm(fields: FrontmatterField[], form: Fo
8
18
  * slicing the ISO string avoids a local-timezone shift.
9
19
  */
10
20
  export declare function dateInputValue(value: unknown): string;
21
+ /**
22
+ * Coerce a frontmatter datetime value to the naive-local, minute-precision `YYYY-MM-DDTHH:mm` an
23
+ * `<input type="datetime-local">` wants. A datetime is round-tripped as TEXT, so a stored value is
24
+ * already this string; the `Date` branch is the fallback for a value gray-matter parsed into a JS
25
+ * `Date` from an unquoted full-ISO scalar. UTC getters read the value back as it was written,
26
+ * avoiding a local-timezone shift.
27
+ */
28
+ export declare function datetimeInputValue(value: unknown): string;
11
29
  /**
12
30
  * True when `s` is a canonical zero-padded `YYYY-MM-DD` string naming a real calendar date.
13
31
  * Rejects a wrong format, an impossible month or day, and a JS date-rollover such as
@@ -16,6 +34,21 @@ export declare function dateInputValue(value: unknown): string;
16
34
  * `dateInputValue` emit, so a value outside it is a hand-edit or odd-YAML error.
17
35
  */
18
36
  export declare function isCalendarDate(s: string): boolean;
37
+ /**
38
+ * Canonicalize one raw reference value to its target id. A Date (a YAML-parsed unquoted date-shaped id)
39
+ * becomes its UTC-sliced `YYYY-MM-DD` string, so a stored id never depends on the reader's timezone; a
40
+ * string is trimmed; anything else is the empty string. `validate`, `extractReferenceEdges`, and
41
+ * `formValues` all read through this, so a hand-edited or rewriter-emitted value canonicalizes identically.
42
+ */
43
+ export declare function referenceIdFromValue(value: unknown): string;
44
+ /**
45
+ * Canonicalize a raw `array(reference)` value to its list of target ids. An array maps each element
46
+ * through `referenceIdFromValue`; a lone non-empty scalar is one element (a single id a YAML scalar
47
+ * carries, never dropped to an empty list); anything else is empty. Empty ids are dropped.
48
+ */
49
+ export declare function referenceIdsFromValue(value: unknown): string[];
50
+ /** Coerce parsed frontmatter to the form-ready values the editor inputs expect, one rule per field type. */
51
+ export declare function formValues(fields: NamedField[], frontmatter: Record<string, unknown>): Record<string, unknown>;
19
52
  /** Reassemble a markdown file from frontmatter and body for committing. */
20
53
  export declare function serializeMarkdown(frontmatter: object, body: string): string;
21
54
  /** Parse a markdown file into its frontmatter and body: the read-side inverse of serialize. */