@glw907/cairn-cms 0.62.2 → 0.68.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 (50) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/dist/auth/types.d.ts +7 -0
  3. package/dist/components/ComponentInsertDialog.svelte +17 -6
  4. package/dist/components/ConceptList.svelte +25 -4
  5. package/dist/components/cairn-admin.css +175 -2
  6. package/dist/content/field-rules.d.ts +15 -0
  7. package/dist/content/field-rules.js +39 -0
  8. package/dist/content/fields.d.ts +121 -0
  9. package/dist/content/fields.js +30 -0
  10. package/dist/content/fieldset.d.ts +86 -0
  11. package/dist/content/fieldset.js +233 -0
  12. package/dist/content/schema.js +16 -20
  13. package/dist/delivery/public-routes.d.ts +8 -0
  14. package/dist/delivery/public-routes.js +10 -1
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +5 -0
  17. package/dist/log/events.d.ts +1 -1
  18. package/dist/media/index.d.ts +1 -1
  19. package/dist/media/index.js +1 -1
  20. package/dist/media/manifest.d.ts +11 -0
  21. package/dist/media/manifest.js +13 -0
  22. package/dist/render/highlight.d.ts +9 -0
  23. package/dist/render/highlight.js +206 -0
  24. package/dist/render/pipeline.js +12 -1
  25. package/dist/render/registry.d.ts +10 -2
  26. package/dist/render/registry.js +21 -1
  27. package/dist/render/rehype-dispatch.d.ts +2 -6
  28. package/dist/render/rehype-dispatch.js +2 -6
  29. package/dist/render/sanitize-schema.d.ts +10 -0
  30. package/dist/render/sanitize-schema.js +29 -0
  31. package/dist/sveltekit/guard.js +10 -0
  32. package/package.json +13 -2
  33. package/src/lib/auth/types.ts +7 -0
  34. package/src/lib/components/ComponentInsertDialog.svelte +17 -6
  35. package/src/lib/components/ConceptList.svelte +41 -4
  36. package/src/lib/content/field-rules.ts +40 -0
  37. package/src/lib/content/fields.ts +127 -0
  38. package/src/lib/content/fieldset.ts +307 -0
  39. package/src/lib/content/schema.ts +9 -13
  40. package/src/lib/delivery/public-routes.ts +19 -1
  41. package/src/lib/index.ts +7 -0
  42. package/src/lib/log/events.ts +1 -0
  43. package/src/lib/media/index.ts +1 -0
  44. package/src/lib/media/manifest.ts +14 -0
  45. package/src/lib/render/highlight.ts +259 -0
  46. package/src/lib/render/pipeline.ts +12 -1
  47. package/src/lib/render/registry.ts +30 -3
  48. package/src/lib/render/rehype-dispatch.ts +2 -6
  49. package/src/lib/render/sanitize-schema.ts +31 -0
  50. package/src/lib/sveltekit/guard.ts +15 -0
@@ -0,0 +1,40 @@
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.
5
+
6
+ /** Compile a field pattern once, throwing a labeled error when the source is not a valid regex. */
7
+ export function compilePattern(source: string, label: string): RegExp {
8
+ try {
9
+ return new RegExp(source);
10
+ } catch (cause) {
11
+ throw new Error(`cairn: field "${label}" has an invalid pattern: ${source}`, { cause });
12
+ }
13
+ }
14
+
15
+ /** Return the first string-length violation message, or null when the value satisfies the bounds. */
16
+ export function stringLengthError(
17
+ value: string,
18
+ constraints: { min?: number; max?: number; length?: number },
19
+ label: string,
20
+ ): string | null {
21
+ const { min, max, length } = constraints;
22
+ if (min != null && value.length < min) return `${label} must be at least ${min} characters`;
23
+ if (max != null && value.length > max) return `${label} must be at most ${max} characters`;
24
+ if (length != null && value.length !== length) return `${label} must be exactly ${length} characters`;
25
+ return null;
26
+ }
27
+
28
+ /** Return the format violation message when a compiled pattern rejects the value, else null. */
29
+ export function patternError(value: string, compiled: RegExp | undefined, label: string): string | null {
30
+ if (compiled && !compiled.test(value)) return `${label} is not in the expected format`;
31
+ return null;
32
+ }
33
+
34
+ /** Return the first date-bounds violation message, or null when the value is within the bounds. */
35
+ export function dateBoundsError(value: string, constraints: { min?: string; max?: string }, label: string): string | null {
36
+ const { min, max } = constraints;
37
+ if (min != null && value < min) return `${label} must be on or after ${min}`;
38
+ if (max != null && value > max) return `${label} must be on or before ${max}`;
39
+ return null;
40
+ }
@@ -0,0 +1,127 @@
1
+ /** The stored value of an image field; re-exported so this module owns the image shape too. */
2
+ export type { ImageValue } from './types.js';
3
+
4
+ /** Common to every field descriptor: the form label and the universal options. */
5
+ export interface FieldBase {
6
+ /** Form label. */
7
+ label: string;
8
+ /** One author-facing sentence shown under the field. */
9
+ help?: string;
10
+ /** A required field fails validation when empty. */
11
+ required?: boolean;
12
+ /** Form-render-time initial value; a sentinel like "today" resolves at render (Task 9). */
13
+ default?: string | boolean;
14
+ }
15
+ /** A single-line text input. */
16
+ export interface TextField extends FieldBase {
17
+ type: 'text';
18
+ min?: number; max?: number; length?: number;
19
+ /** A regular-expression source string the value must match. */
20
+ pattern?: string;
21
+ }
22
+ /** A multi-line text input. */
23
+ export interface TextareaField extends FieldBase {
24
+ type: 'textarea';
25
+ rows?: number; min?: number; max?: number; length?: number; pattern?: string;
26
+ }
27
+ /** A numeric input. */
28
+ export interface NumberField extends FieldBase {
29
+ type: 'number';
30
+ min?: number; max?: number;
31
+ /** Constrain the value to whole numbers. */
32
+ integer?: boolean;
33
+ }
34
+ /** A single-choice input over a closed option list. */
35
+ export interface SelectField extends FieldBase {
36
+ type: 'select';
37
+ /** The closed set of allowed values. */
38
+ options: readonly string[];
39
+ }
40
+ /** A multiple-choice input. */
41
+ export interface MultiselectField extends FieldBase {
42
+ type: 'multiselect';
43
+ /** The allowed values; omitted leaves the set open. */
44
+ options?: readonly string[];
45
+ /** Allow the author to add values not in the list. */
46
+ creatable?: boolean;
47
+ /** Mark the field as a site-wide taxonomy whose values pool across entries. */
48
+ taxonomy?: boolean;
49
+ }
50
+ /** A URL input whose format the validator enforces. */
51
+ export interface UrlField extends FieldBase {
52
+ type: 'url';
53
+ }
54
+ /** An email-address input whose format the validator enforces. */
55
+ export interface EmailField extends FieldBase {
56
+ type: 'email';
57
+ }
58
+ /** A calendar-date input. */
59
+ export interface DateField extends FieldBase {
60
+ type: 'date';
61
+ /** Earliest allowed date as YYYY-MM-DD. */
62
+ min?: string;
63
+ /** Latest allowed date as YYYY-MM-DD. */
64
+ max?: string;
65
+ }
66
+ /** A date-and-time input. */
67
+ export interface DatetimeField extends FieldBase {
68
+ type: 'datetime';
69
+ /** Earliest allowed moment as an ISO string. */
70
+ min?: string;
71
+ /** Latest allowed moment as an ISO string. */
72
+ max?: string;
73
+ }
74
+ /** A checkbox; absent means false. */
75
+ export interface BooleanField extends FieldBase {
76
+ type: 'boolean';
77
+ }
78
+ /** A hero image whose stored value is the nested ImageValue object. */
79
+ export interface ImageField extends FieldBase {
80
+ type: 'image';
81
+ /** Whether this field feeds the social-card image. */
82
+ seo?: boolean;
83
+ }
84
+ /** The plain-data descriptor union the form, validator, and inference all read. Grows per task. */
85
+ export type FieldDescriptor =
86
+ | TextField
87
+ | TextareaField
88
+ | NumberField
89
+ | SelectField
90
+ | MultiselectField
91
+ | UrlField
92
+ | EmailField
93
+ | DateField
94
+ | DatetimeField
95
+ | BooleanField
96
+ | ImageField;
97
+
98
+ /**
99
+ * The constructor namespace a concept declares its fields with. Each constructor captures its
100
+ * argument with a `const` type parameter and intersects it onto the descriptor, so the call-site
101
+ * literals (`required: true`, a `select`/`multiselect` `options` union) survive into the descriptor
102
+ * type for `Infer` to read. The runtime value is unchanged: still `{ type, ...o }`.
103
+ */
104
+ export const fields = {
105
+ /** A single-line text field. */
106
+ text: <const O extends Omit<TextField, 'type'>>(o: O): TextField & O => ({ type: 'text', ...o }),
107
+ /** A multi-line text field. */
108
+ textarea: <const O extends Omit<TextareaField, 'type'>>(o: O): TextareaField & O => ({ type: 'textarea', ...o }),
109
+ /** A numeric field. */
110
+ number: <const O extends Omit<NumberField, 'type'>>(o: O): NumberField & O => ({ type: 'number', ...o }),
111
+ /** A single-choice field over a closed option list, preserving the literal option union. */
112
+ select: <const O extends Omit<SelectField, 'type'>>(o: O): SelectField & O => ({ type: 'select', ...o }),
113
+ /** A multiple-choice field, preserving the literal option union when one is given. */
114
+ multiselect: <const O extends Omit<MultiselectField, 'type'>>(o: O): MultiselectField & O => ({ type: 'multiselect', ...o }),
115
+ /** A URL field. */
116
+ url: <const O extends Omit<UrlField, 'type'>>(o: O): UrlField & O => ({ type: 'url', ...o }),
117
+ /** An email-address field. */
118
+ email: <const O extends Omit<EmailField, 'type'>>(o: O): EmailField & O => ({ type: 'email', ...o }),
119
+ /** A calendar-date field. */
120
+ date: <const O extends Omit<DateField, 'type'>>(o: O): DateField & O => ({ type: 'date', ...o }),
121
+ /** A date-and-time field. */
122
+ datetime: <const O extends Omit<DatetimeField, 'type'>>(o: O): DatetimeField & O => ({ type: 'datetime', ...o }),
123
+ /** A boolean checkbox field. */
124
+ boolean: <const O extends Omit<BooleanField, 'type'>>(o: O): BooleanField & O => ({ type: 'boolean', ...o }),
125
+ /** An image field whose value is the nested ImageValue object. */
126
+ image: <const O extends Omit<ImageField, 'type'>>(o: O): ImageField & O => ({ type: 'image', ...o }),
127
+ };
@@ -0,0 +1,307 @@
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.
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';
10
+ import { compilePattern, dateBoundsError, patternError, stringLengthError } from './field-rules.js';
11
+
12
+ /** Accept any URL using http or https with a non-empty rest, mirroring the conservative form check. */
13
+ const URL_RE = /^https?:\/\/\S+$/;
14
+ /** Accept a single address conservatively: exactly one at-sign and a dotted domain, nothing more. */
15
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
16
+
17
+ /**
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.
21
+ */
22
+ export type BehaviorTable = Record<string, never>;
23
+
24
+ /**
25
+ * Options for `fieldset`. `refine` runs after the per-field coercion and constraints pass, for
26
+ * cross-field and body-dependent checks. It is validation-only: it returns field-keyed errors to
27
+ * merge, or nothing, and never transforms the data. Server-only, since it may carry closures.
28
+ */
29
+ export interface FieldsetOptions {
30
+ refine?: (data: Record<string, unknown>, body: string) => Record<string, string> | undefined;
31
+ }
32
+
33
+ /**
34
+ * A concept's fieldset: the plain-data descriptors, the co-bundled behavior table, the server-derived
35
+ * validator, and the Standard Schema conformance property.
36
+ */
37
+ export interface Fieldset<R extends Record<string, FieldDescriptor> = Record<string, FieldDescriptor>> {
38
+ /** The declared descriptors as plain serializable data, for the editor form. */
39
+ readonly fields: R;
40
+ /** Function-valued behavior keyed by field name; empty for a scalar-only fieldset. */
41
+ readonly behavior: BehaviorTable;
42
+ /** Validate raw frontmatter, returning field-keyed errors or the normalized data. */
43
+ validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
44
+ /** Standard Schema v1 conformance, for ecosystem interop. A thin adapter over `validate`. */
45
+ readonly '~standard': StandardSchemaV1<StandardInput, Record<string, unknown>>['~standard'];
46
+ }
47
+
48
+ /**
49
+ * Map one field descriptor to the TS type of its normalized value. number is number, boolean is
50
+ * boolean, image is the nested ImageValue object; a select with a literal option list is that
51
+ * option union, a multiselect with one is that union array (else string[]); everything else is a
52
+ * string.
53
+ */
54
+ type ValueOf<D extends FieldDescriptor> = D extends { type: 'number' }
55
+ ? number
56
+ : D extends { type: 'boolean' }
57
+ ? boolean
58
+ : D extends { type: 'image' }
59
+ ? 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;
67
+
68
+ /** Flatten an intersection into a single readable object type. */
69
+ type Prettify<T> = { [K in keyof T]: T[K] } & {};
70
+
71
+ /**
72
+ * 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.
74
+ */
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]>;
78
+ }
79
+ >;
80
+
81
+ /** 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
+ }
112
+
113
+ // Coerce a raw value to the trimmed string the empty check and constraints run on. A parsed value may
114
+ // arrive from parseMarkdown, not only a form string: a Date on a date or datetime field, a JS number on
115
+ // a number field. A finite 0 coerces to '0', never read as empty, since 0 is a real number a YAML scalar
116
+ // carries; a NaN or non-finite number stays '' and routes to the number error in validateField.
117
+ function coerceToText(type: FieldDescriptor['type'], value: unknown): string {
118
+ if (type === 'date' && value instanceof Date) return dateInputValue(value);
119
+ if (type === 'datetime' && value instanceof Date) return value.toISOString();
120
+ if (type === 'number' && typeof value === 'number' && Number.isFinite(value)) return String(value);
121
+ if (typeof value === 'string') return value.trim();
122
+ return '';
123
+ }
124
+
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.
130
+ function validateField(
131
+ key: string,
132
+ field: FieldDescriptor,
133
+ value: unknown,
134
+ data: Record<string, unknown>,
135
+ errors: Record<string, string>,
136
+ patterns: Map<string, RegExp>,
137
+ ): void {
138
+ // boolean: presence is the value; an unchecked or absent box omits the key (no draft: false noise).
139
+ if (field.type === 'boolean') {
140
+ if (value === true) data[key] = true;
141
+ return;
142
+ }
143
+
144
+ // 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.
149
+ if (field.type === 'multiselect') {
150
+ let raw: string[];
151
+ if (Array.isArray(value)) raw = value.map(String);
152
+ else if (typeof value === 'string' && value.trim() !== '') raw = [value.trim()];
153
+ else raw = [];
154
+ const list = raw.map((v) => v.trim()).filter((v) => v !== '');
155
+ if (field.required && list.length === 0) {
156
+ errors[key] = `${field.label} is required`;
157
+ return;
158
+ }
159
+ const { options } = field;
160
+ if (options) {
161
+ const unknown = list.find((v) => !options.includes(v));
162
+ if (unknown !== undefined) {
163
+ errors[key] = `${field.label} contains an unknown value: ${unknown}`;
164
+ return;
165
+ }
166
+ }
167
+ if (list.length > 0) data[key] = list;
168
+ return;
169
+ }
170
+
171
+ // image: the nested object arm, dropping the key on empty src.
172
+ if (field.type === 'image') {
173
+ coerceImage(field, key, value, data, errors);
174
+ return;
175
+ }
176
+
177
+ // Every other type is "not provided when empty" first, then coerced. `coerceToText` turns a parsed
178
+ // value into its string form BEFORE the empty check, so a real parsed value (a Date on a date or
179
+ // datetime field, a number on a number field) is not read as empty.
180
+ const text = coerceToText(field.type, value);
181
+ if (text === '') {
182
+ if (field.required) errors[key] = `${field.label} is required`;
183
+ return;
184
+ }
185
+
186
+ switch (field.type) {
187
+ case 'number': {
188
+ const n = Number(text);
189
+ // Reject NaN and the non-finite values Number() yields for "Infinity"/"1e400", which an
190
+ // 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;
197
+ }
198
+ case 'select': {
199
+ if (!field.options.includes(text)) errors[key] = `${field.label} contains an unknown value: ${text}`;
200
+ else data[key] = text;
201
+ break;
202
+ }
203
+ case 'url': {
204
+ if (!URL_RE.test(text)) errors[key] = `${field.label} is not a valid URL`;
205
+ else data[key] = text;
206
+ break;
207
+ }
208
+ 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;
212
+ }
213
+ 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;
225
+ }
226
+ default: {
227
+ // text, textarea, datetime: a trimmed non-empty string. text and textarea also enforce the
228
+ // string-length and pattern constraints (v1 parity); datetime stays a plain string for now,
229
+ // since its bounds are out of scope this pass and v1 has no datetime equivalent to match.
230
+ 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;
240
+ }
241
+ }
242
+ data[key] = text;
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Build a fieldset from a key-to-descriptor record. The returned schema carries the descriptors, a
249
+ * server-derived validator that coerces per type and returns field-keyed errors or normalized data,
250
+ * and the Standard Schema conformance property whose issues map each error to a single-segment path.
251
+ */
252
+ export function fieldset<const R extends Record<string, FieldDescriptor>>(
253
+ record: R,
254
+ options: FieldsetOptions = {},
255
+ ): Fieldset<R> {
256
+ // 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.
258
+ 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));
262
+ }
263
+ }
264
+ const validate = (frontmatter: Record<string, unknown>, body: string): ValidationResult => {
265
+ const data: Record<string, unknown> = {};
266
+ const errors: Record<string, string> = {};
267
+ for (const [key, field] of Object.entries(record)) {
268
+ validateField(key, field, frontmatter[key], data, errors, patterns);
269
+ }
270
+ if (Object.keys(errors).length > 0) return { ok: false, errors };
271
+ const refined = options.refine?.(data, body);
272
+ return refined && Object.keys(refined).length > 0 ? { ok: false, errors: refined } : { ok: true, data };
273
+ };
274
+ const standard: StandardSchemaV1<StandardInput, Record<string, unknown>>['~standard'] = {
275
+ version: 1,
276
+ vendor: 'cairn',
277
+ validate: (value) => {
278
+ const { frontmatter = {}, body = '' } = (value ?? {}) as Partial<StandardInput>;
279
+ const result = validate(frontmatter ?? {}, body ?? '');
280
+ return result.ok
281
+ ? { value: result.data }
282
+ : { issues: Object.entries(result.errors).map(([key, message]) => ({ message, path: [key] })) };
283
+ },
284
+ };
285
+ return { fields: record, behavior: {}, validate, '~standard': standard };
286
+ }
287
+
288
+ /**
289
+ * Resolve each descriptor's `default` to a form-initial value, so a fresh entry opens prefilled. The
290
+ * `'today'` sentinel on a date field resolves through the passed `now` to its `YYYY-MM-DD` form; an
291
+ * empty-string or `false` default is omitted, so an untouched field commits no key (the
292
+ * minimal-frontmatter invariant). With no `now`, a `'today'` default is omitted rather than read off
293
+ * a real clock, since library code must stay deterministic and Workers-safe.
294
+ */
295
+ export function initialValues(fieldset: Fieldset, now?: Date): Record<string, unknown> {
296
+ const values: Record<string, unknown> = {};
297
+ for (const [key, field] of Object.entries(fieldset.fields)) {
298
+ const value = field.default;
299
+ if (value === undefined || value === '' || value === false) continue;
300
+ if (field.type === 'date' && value === 'today') {
301
+ if (now) values[key] = now.toISOString().slice(0, 10);
302
+ continue;
303
+ }
304
+ values[key] = value;
305
+ }
306
+ return values;
307
+ }
@@ -2,6 +2,7 @@
2
2
  // declaration yields a plain-data field projection for the editor form, a generated validator,
3
3
  // and an inferred frontmatter type. Plan 1 builds the additive primitive; the adapter-contract
4
4
  // cutover and the typed reads are Plan 2.
5
+ import { compilePattern, dateBoundsError, patternError, stringLengthError } from './field-rules.js';
5
6
  import type { FrontmatterField, ImageValue, ValidationResult } from './types.js';
6
7
  import { validateFields } from './validate.js';
7
8
 
@@ -77,16 +78,15 @@ export type Infer<S> = S extends ConceptSchema<infer F> ? InferFields<F> : never
77
78
  function applyRules(field: FrontmatterField, value: unknown, errors: Record<string, string>, patterns: Map<string, RegExp>): void {
78
79
  if (typeof value !== 'string' || value === '') return;
79
80
  if (field.type === 'text' || field.type === 'textarea') {
80
- if (field.min != null && value.length < field.min) errors[field.name] = `${field.label} must be at least ${field.min} characters`;
81
- else if (field.max != null && value.length > field.max) errors[field.name] = `${field.label} must be at most ${field.max} characters`;
82
- else if (field.length != null && value.length !== field.length) errors[field.name] = `${field.label} must be exactly ${field.length} characters`;
83
- else if (field.pattern != null) {
84
- const re = patterns.get(field.name);
85
- if (re && !re.test(value)) errors[field.name] = `${field.label} is not in the expected format`;
81
+ const lengthError = stringLengthError(value, field, field.label);
82
+ if (lengthError != null) errors[field.name] = lengthError;
83
+ else {
84
+ const formatError = patternError(value, patterns.get(field.name), field.label);
85
+ if (formatError != null) errors[field.name] = formatError;
86
86
  }
87
87
  } else if (field.type === 'date') {
88
- if (field.min != null && value < field.min) errors[field.name] = `${field.label} must be on or after ${field.min}`;
89
- else if (field.max != null && value > field.max) errors[field.name] = `${field.label} must be on or before ${field.max}`;
88
+ const boundsError = dateBoundsError(value, field, field.label);
89
+ if (boundsError != null) errors[field.name] = boundsError;
90
90
  }
91
91
  }
92
92
 
@@ -105,11 +105,7 @@ function compilePatterns(fields: FrontmatterField[]): Map<string, RegExp> {
105
105
  const compiled = new Map<string, RegExp>();
106
106
  for (const field of fields) {
107
107
  if ((field.type === 'text' || field.type === 'textarea') && field.pattern != null) {
108
- try {
109
- compiled.set(field.name, new RegExp(field.pattern));
110
- } catch (cause) {
111
- throw new Error(`cairn: field "${field.name}" has an invalid pattern: ${field.pattern}`, { cause });
112
- }
108
+ compiled.set(field.name, compilePattern(field.pattern, field.name));
113
109
  }
114
110
  }
115
111
  return compiled;
@@ -13,6 +13,7 @@ import { buildLinkResolver } from './site-resolver.js';
13
13
  import type { LinkResolve } from '../content/links.js';
14
14
  import type { MediaResolve } from '../render/resolve-media.js';
15
15
  import { parseMediaToken } from '../media/reference.js';
16
+ import { log } from '../log/index.js';
16
17
 
17
18
  /** Injected dependencies for the public loaders. */
18
19
  export interface PublicRoutesDeps {
@@ -36,6 +37,14 @@ export interface PublicRoutesDeps {
36
37
  * media is off and no `heroImage` projection is derived.
37
38
  */
38
39
  resolveMedia?: MediaResolve;
40
+ /**
41
+ * Whether the site configured media on, read from `runtime.resolvedAssets.enabled`. It exists only
42
+ * to diagnose a forgotten wire-point: media on but no `resolveMedia` reached this factory, which
43
+ * renders public hero and body images as bare `media:` tokens. When true and `resolveMedia` is
44
+ * absent, the factory emits `media.resolver_absent` once at construction. It does not change
45
+ * resolution; `resolveMedia` alone still gates the hero projection.
46
+ */
47
+ assetsEnabled?: boolean;
39
48
  }
40
49
 
41
50
  /** The archive and tag list data: summaries the template renders. */
@@ -74,7 +83,16 @@ export interface EntryData {
74
83
 
75
84
  /** Build the public loaders for a site's unified index. */
76
85
  export function createPublicRoutes(deps: PublicRoutesDeps) {
77
- const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia } = deps;
86
+ const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia, assetsEnabled } = deps;
87
+
88
+ // Diagnose a forgotten wire-point: media is configured on but no resolver reached this factory, so
89
+ // every public hero and body `media:` token renders bare (the ecxc 0.57.0 finding). The condition
90
+ // is a property of the wiring, not of any one load, so it is checked once here at construction
91
+ // rather than per entryLoad or per image, which keeps the warning loud-once and out of the
92
+ // prerender hot path. Resolution is unchanged; resolveMedia alone still gates the hero projection.
93
+ if (assetsEnabled && !resolveMedia) {
94
+ log.warn('media.resolver_absent', { enabled: true });
95
+ }
78
96
 
79
97
  /**
80
98
  * Derive the hero projection from an entry's frontmatter, without mutating it (locked decision 5).
package/src/lib/index.ts CHANGED
@@ -45,6 +45,13 @@ export {
45
45
  export { defineFields } from './content/schema.js';
46
46
  export { defineAdapter } from './content/adapter.js';
47
47
  export type { ConceptSchema, Infer, InferFields, DefineFieldsOptions, StandardInput, StandardSchemaV1 } from './content/schema.js';
48
+ // The Contract v2 field vocabulary, additive beside `defineFields`. The individual *Field
49
+ // interfaces and the bare `Infer` stay module-local: the old `FrontmatterField` model above
50
+ // already exports those names, and the cutover plan frees them.
51
+ export { fields } from './content/fields.js';
52
+ export type { FieldDescriptor } from './content/fields.js';
53
+ export { fieldset, initialValues } from './content/fieldset.js';
54
+ export type { Fieldset, InferFieldset, FieldsetOptions, BehaviorTable } from './content/fieldset.js';
48
55
  export {
49
56
  isValidId,
50
57
  idFromFilename,
@@ -22,6 +22,7 @@ export type CairnLogEvent =
22
22
  | 'media.delivery_failed'
23
23
  | 'media.orphan_reconcile'
24
24
  | 'media.resolve_missing'
25
+ | 'media.resolver_absent'
25
26
  | 'media.deleted'
26
27
  | 'media.delete_blocked'
27
28
  | 'media.bulk_deleted'
@@ -8,6 +8,7 @@
8
8
  export { normalizeAssets, type ResolvedAssetConfig } from './config.js';
9
9
  export {
10
10
  parseMediaManifest,
11
+ readCommittedManifest,
11
12
  findByHash,
12
13
  upsertMediaEntry,
13
14
  removeMediaEntry,
@@ -38,6 +38,20 @@ export function parseMediaManifest(json: unknown): MediaManifest {
38
38
  return json as MediaManifest;
39
39
  }
40
40
 
41
+ /**
42
+ * Read the committed media manifest from an `import.meta.glob` eager result, degrading a missing
43
+ * file to an empty manifest. A static import of an absent `media.json` fails the Vite build before
44
+ * any runtime degrade can run, so a fresh site with no manifest cannot build. A glob result is the
45
+ * build-safe read: `import.meta.glob` returns `{}` when nothing matches rather than throwing, and
46
+ * this helper extracts the single matched value and parses it, so a missing file reads a clean `{}`.
47
+ * @param globResult - The eager glob result for the committed manifest, an empty object when the
48
+ * file is absent. The consumer passes
49
+ * `import.meta.glob('<path-to-media.json>', { eager: true, import: 'default' })`.
50
+ */
51
+ export function readCommittedManifest(globResult: Record<string, unknown>): MediaManifest {
52
+ return parseMediaManifest(Object.values(globResult)[0]);
53
+ }
54
+
41
55
  /**
42
56
  * Validate one posted value as a MediaEntry, returning it narrowed or undefined. The trust boundary
43
57
  * for an optimistic record the client re-posts: the upload action server-owned each field at