@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,121 @@
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
+ /** Common to every field descriptor: the form label and the universal options. */
4
+ export interface FieldBase {
5
+ /** Form label. */
6
+ label: string;
7
+ /** One author-facing sentence shown under the field. */
8
+ help?: string;
9
+ /** A required field fails validation when empty. */
10
+ required?: boolean;
11
+ /** Form-render-time initial value; a sentinel like "today" resolves at render (Task 9). */
12
+ default?: string | boolean;
13
+ }
14
+ /** A single-line text input. */
15
+ export interface TextField extends FieldBase {
16
+ type: 'text';
17
+ min?: number;
18
+ max?: number;
19
+ length?: number;
20
+ /** A regular-expression source string the value must match. */
21
+ pattern?: string;
22
+ }
23
+ /** A multi-line text input. */
24
+ export interface TextareaField extends FieldBase {
25
+ type: 'textarea';
26
+ rows?: number;
27
+ min?: number;
28
+ max?: number;
29
+ length?: number;
30
+ pattern?: string;
31
+ }
32
+ /** A numeric input. */
33
+ export interface NumberField extends FieldBase {
34
+ type: 'number';
35
+ min?: number;
36
+ max?: number;
37
+ /** Constrain the value to whole numbers. */
38
+ integer?: boolean;
39
+ }
40
+ /** A single-choice input over a closed option list. */
41
+ export interface SelectField extends FieldBase {
42
+ type: 'select';
43
+ /** The closed set of allowed values. */
44
+ options: readonly string[];
45
+ }
46
+ /** A multiple-choice input. */
47
+ export interface MultiselectField extends FieldBase {
48
+ type: 'multiselect';
49
+ /** The allowed values; omitted leaves the set open. */
50
+ options?: readonly string[];
51
+ /** Allow the author to add values not in the list. */
52
+ creatable?: boolean;
53
+ /** Mark the field as a site-wide taxonomy whose values pool across entries. */
54
+ taxonomy?: boolean;
55
+ }
56
+ /** A URL input whose format the validator enforces. */
57
+ export interface UrlField extends FieldBase {
58
+ type: 'url';
59
+ }
60
+ /** An email-address input whose format the validator enforces. */
61
+ export interface EmailField extends FieldBase {
62
+ type: 'email';
63
+ }
64
+ /** A calendar-date input. */
65
+ export interface DateField extends FieldBase {
66
+ type: 'date';
67
+ /** Earliest allowed date as YYYY-MM-DD. */
68
+ min?: string;
69
+ /** Latest allowed date as YYYY-MM-DD. */
70
+ max?: string;
71
+ }
72
+ /** A date-and-time input. */
73
+ export interface DatetimeField extends FieldBase {
74
+ type: 'datetime';
75
+ /** Earliest allowed moment as an ISO string. */
76
+ min?: string;
77
+ /** Latest allowed moment as an ISO string. */
78
+ max?: string;
79
+ }
80
+ /** A checkbox; absent means false. */
81
+ export interface BooleanField extends FieldBase {
82
+ type: 'boolean';
83
+ }
84
+ /** A hero image whose stored value is the nested ImageValue object. */
85
+ export interface ImageField extends FieldBase {
86
+ type: 'image';
87
+ /** Whether this field feeds the social-card image. */
88
+ seo?: boolean;
89
+ }
90
+ /** The plain-data descriptor union the form, validator, and inference all read. Grows per task. */
91
+ export type FieldDescriptor = TextField | TextareaField | NumberField | SelectField | MultiselectField | UrlField | EmailField | DateField | DatetimeField | BooleanField | ImageField;
92
+ /**
93
+ * The constructor namespace a concept declares its fields with. Each constructor captures its
94
+ * argument with a `const` type parameter and intersects it onto the descriptor, so the call-site
95
+ * literals (`required: true`, a `select`/`multiselect` `options` union) survive into the descriptor
96
+ * type for `Infer` to read. The runtime value is unchanged: still `{ type, ...o }`.
97
+ */
98
+ export declare const fields: {
99
+ /** A single-line text field. */
100
+ text: <const O extends Omit<TextField, "type">>(o: O) => TextField & O;
101
+ /** A multi-line text field. */
102
+ textarea: <const O extends Omit<TextareaField, "type">>(o: O) => TextareaField & O;
103
+ /** A numeric field. */
104
+ number: <const O extends Omit<NumberField, "type">>(o: O) => NumberField & O;
105
+ /** A single-choice field over a closed option list, preserving the literal option union. */
106
+ select: <const O extends Omit<SelectField, "type">>(o: O) => SelectField & O;
107
+ /** A multiple-choice field, preserving the literal option union when one is given. */
108
+ multiselect: <const O extends Omit<MultiselectField, "type">>(o: O) => MultiselectField & O;
109
+ /** A URL field. */
110
+ url: <const O extends Omit<UrlField, "type">>(o: O) => UrlField & O;
111
+ /** An email-address field. */
112
+ email: <const O extends Omit<EmailField, "type">>(o: O) => EmailField & O;
113
+ /** A calendar-date field. */
114
+ date: <const O extends Omit<DateField, "type">>(o: O) => DateField & O;
115
+ /** A date-and-time field. */
116
+ datetime: <const O extends Omit<DatetimeField, "type">>(o: O) => DatetimeField & O;
117
+ /** A boolean checkbox field. */
118
+ boolean: <const O extends Omit<BooleanField, "type">>(o: O) => BooleanField & O;
119
+ /** An image field whose value is the nested ImageValue object. */
120
+ image: <const O extends Omit<ImageField, "type">>(o: O) => ImageField & O;
121
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The constructor namespace a concept declares its fields with. Each constructor captures its
3
+ * argument with a `const` type parameter and intersects it onto the descriptor, so the call-site
4
+ * literals (`required: true`, a `select`/`multiselect` `options` union) survive into the descriptor
5
+ * type for `Infer` to read. The runtime value is unchanged: still `{ type, ...o }`.
6
+ */
7
+ export const fields = {
8
+ /** A single-line text field. */
9
+ text: (o) => ({ type: 'text', ...o }),
10
+ /** A multi-line text field. */
11
+ textarea: (o) => ({ type: 'textarea', ...o }),
12
+ /** A numeric field. */
13
+ number: (o) => ({ type: 'number', ...o }),
14
+ /** A single-choice field over a closed option list, preserving the literal option union. */
15
+ select: (o) => ({ type: 'select', ...o }),
16
+ /** A multiple-choice field, preserving the literal option union when one is given. */
17
+ multiselect: (o) => ({ type: 'multiselect', ...o }),
18
+ /** A URL field. */
19
+ url: (o) => ({ type: 'url', ...o }),
20
+ /** An email-address field. */
21
+ email: (o) => ({ type: 'email', ...o }),
22
+ /** A calendar-date field. */
23
+ date: (o) => ({ type: 'date', ...o }),
24
+ /** A date-and-time field. */
25
+ datetime: (o) => ({ type: 'datetime', ...o }),
26
+ /** A boolean checkbox field. */
27
+ boolean: (o) => ({ type: 'boolean', ...o }),
28
+ /** An image field whose value is the nested ImageValue object. */
29
+ image: (o) => ({ type: 'image', ...o }),
30
+ };
@@ -0,0 +1,86 @@
1
+ import type { FieldDescriptor, ImageValue } from './fields.js';
2
+ import type { ValidationResult } from './types.js';
3
+ import type { StandardInput, StandardSchemaV1 } from './schema.js';
4
+ /**
5
+ * The behavior table co-bundled with a fieldset, keyed by field name. It holds function-valued
6
+ * behavior a descriptor cannot carry as plain data (a cross-field validator, an array itemLabel).
7
+ * Scalars have no behavior, so the table is empty for now and reserved for later co-bundled functions.
8
+ */
9
+ export type BehaviorTable = Record<string, never>;
10
+ /**
11
+ * Options for `fieldset`. `refine` runs after the per-field coercion and constraints pass, for
12
+ * cross-field and body-dependent checks. It is validation-only: it returns field-keyed errors to
13
+ * merge, or nothing, and never transforms the data. Server-only, since it may carry closures.
14
+ */
15
+ export interface FieldsetOptions {
16
+ refine?: (data: Record<string, unknown>, body: string) => Record<string, string> | undefined;
17
+ }
18
+ /**
19
+ * A concept's fieldset: the plain-data descriptors, the co-bundled behavior table, the server-derived
20
+ * validator, and the Standard Schema conformance property.
21
+ */
22
+ export interface Fieldset<R extends Record<string, FieldDescriptor> = Record<string, FieldDescriptor>> {
23
+ /** The declared descriptors as plain serializable data, for the editor form. */
24
+ readonly fields: R;
25
+ /** Function-valued behavior keyed by field name; empty for a scalar-only fieldset. */
26
+ readonly behavior: BehaviorTable;
27
+ /** Validate raw frontmatter, returning field-keyed errors or the normalized data. */
28
+ validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
29
+ /** Standard Schema v1 conformance, for ecosystem interop. A thin adapter over `validate`. */
30
+ readonly '~standard': StandardSchemaV1<StandardInput, Record<string, unknown>>['~standard'];
31
+ }
32
+ /**
33
+ * Map one field descriptor to the TS type of its normalized value. number is number, boolean is
34
+ * boolean, image is the nested ImageValue object; a select with a literal option list is that
35
+ * option union, a multiselect with one is that union array (else string[]); everything else is a
36
+ * string.
37
+ */
38
+ type ValueOf<D extends FieldDescriptor> = D extends {
39
+ type: 'number';
40
+ } ? number : D extends {
41
+ type: 'boolean';
42
+ } ? boolean : D extends {
43
+ type: 'image';
44
+ } ? ImageValue : D extends {
45
+ type: 'select';
46
+ options: readonly (infer O extends string)[];
47
+ } ? O : D extends {
48
+ type: 'multiselect';
49
+ options: readonly (infer O extends string)[];
50
+ } ? O[] : D extends {
51
+ type: 'multiselect';
52
+ } ? string[] : string;
53
+ /** Flatten an intersection into a single readable object type. */
54
+ type Prettify<T> = {
55
+ [K in keyof T]: T[K];
56
+ } & {};
57
+ /**
58
+ * The normalized frontmatter type inferred from a fieldset's descriptor record. A descriptor
59
+ * declared `required: true` is a required key; every other descriptor is optional.
60
+ */
61
+ type Infer<R extends Record<string, FieldDescriptor>> = Prettify<{
62
+ -readonly [K in keyof R as R[K] extends {
63
+ required: true;
64
+ } ? K : never]: ValueOf<R[K]>;
65
+ } & {
66
+ -readonly [K in keyof R as R[K] extends {
67
+ required: true;
68
+ } ? never : K]?: ValueOf<R[K]>;
69
+ }>;
70
+ /** Extract the inferred frontmatter type from a `Fieldset`. */
71
+ export type InferFieldset<S> = S extends Fieldset<infer R> ? Infer<R> : never;
72
+ /**
73
+ * Build a fieldset from a key-to-descriptor record. The returned schema carries the descriptors, a
74
+ * server-derived validator that coerces per type and returns field-keyed errors or normalized data,
75
+ * and the Standard Schema conformance property whose issues map each error to a single-segment path.
76
+ */
77
+ export declare function fieldset<const R extends Record<string, FieldDescriptor>>(record: R, options?: FieldsetOptions): Fieldset<R>;
78
+ /**
79
+ * Resolve each descriptor's `default` to a form-initial value, so a fresh entry opens prefilled. The
80
+ * `'today'` sentinel on a date field resolves through the passed `now` to its `YYYY-MM-DD` form; an
81
+ * empty-string or `false` default is omitted, so an untouched field commits no key (the
82
+ * minimal-frontmatter invariant). With no `now`, a `'today'` default is omitted rather than read off
83
+ * a real clock, since library code must stay deterministic and Workers-safe.
84
+ */
85
+ export declare function initialValues(fieldset: Fieldset, now?: Date): Record<string, unknown>;
86
+ export {};
@@ -0,0 +1,233 @@
1
+ import { dateInputValue, isCalendarDate } from './frontmatter.js';
2
+ import { compilePattern, dateBoundsError, patternError, stringLengthError } from './field-rules.js';
3
+ /** Accept any URL using http or https with a non-empty rest, mirroring the conservative form check. */
4
+ const URL_RE = /^https?:\/\/\S+$/;
5
+ /** Accept a single address conservatively: exactly one at-sign and a dotted domain, nothing more. */
6
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
7
+ // Coerce one image value to the stored `{ src, alt, caption?, decorative? }` shape, ported from
8
+ // validate.ts. Default a missing alt to empty (alt is debt, never a save block), trim and drop a
9
+ // blank caption, keep decorative only when an explicit true, and drop the whole key when src is empty.
10
+ // A required image with an empty src is the one error this arm raises.
11
+ function coerceImage(field, key, value, data, errors) {
12
+ let src = '';
13
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
14
+ const obj = value;
15
+ src = typeof obj.src === 'string' ? obj.src.trim() : '';
16
+ if (src !== '') {
17
+ const normalized = {
18
+ src,
19
+ alt: typeof obj.alt === 'string' ? obj.alt : '',
20
+ };
21
+ const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
22
+ if (caption !== '')
23
+ normalized.caption = caption;
24
+ if (obj.decorative === true)
25
+ normalized.decorative = true;
26
+ data[key] = normalized;
27
+ }
28
+ }
29
+ if (field.required && src === '')
30
+ errors[key] = `${field.label} is required`;
31
+ }
32
+ // Coerce a raw value to the trimmed string the empty check and constraints run on. A parsed value may
33
+ // arrive from parseMarkdown, not only a form string: a Date on a date or datetime field, a JS number on
34
+ // a number field. A finite 0 coerces to '0', never read as empty, since 0 is a real number a YAML scalar
35
+ // carries; a NaN or non-finite number stays '' and routes to the number error in validateField.
36
+ function coerceToText(type, value) {
37
+ if (type === 'date' && value instanceof Date)
38
+ return dateInputValue(value);
39
+ if (type === 'datetime' && value instanceof Date)
40
+ return value.toISOString();
41
+ if (type === 'number' && typeof value === 'number' && Number.isFinite(value))
42
+ return String(value);
43
+ if (typeof value === 'string')
44
+ return value.trim();
45
+ return '';
46
+ }
47
+ // Validate one descriptor against its raw value, writing into `data` or `errors`. Empty or absent is
48
+ // "not provided" and is read BEFORE type coercion, uniformly: a required field errors, an optional
49
+ // field drops (no key, no error). Only a non-empty value is coerced. boolean is the exception: true
50
+ // stores true, anything else omits the key. number relies on the empty-first drop so an empty optional
51
+ // number never becomes Number('') === 0.
52
+ function validateField(key, field, value, data, errors, patterns) {
53
+ // boolean: presence is the value; an unchecked or absent box omits the key (no draft: false noise).
54
+ if (field.type === 'boolean') {
55
+ if (value === true)
56
+ data[key] = true;
57
+ return;
58
+ }
59
+ // multiselect: a string array; drop empties, reject an unknown value when options is closed. An empty
60
+ // list omits the key (a required empty errors); the array path is the one non-string coercion. A lone
61
+ // non-empty scalar (a single tag a YAML scalar carries) coerces to a single-element list, rather than
62
+ // dropping to [] and reading as "required" while present. An empty string or a non-string-non-array
63
+ // stays the empty list.
64
+ if (field.type === 'multiselect') {
65
+ let raw;
66
+ if (Array.isArray(value))
67
+ raw = value.map(String);
68
+ else if (typeof value === 'string' && value.trim() !== '')
69
+ raw = [value.trim()];
70
+ else
71
+ raw = [];
72
+ const list = raw.map((v) => v.trim()).filter((v) => v !== '');
73
+ if (field.required && list.length === 0) {
74
+ errors[key] = `${field.label} is required`;
75
+ return;
76
+ }
77
+ const { options } = field;
78
+ if (options) {
79
+ const unknown = list.find((v) => !options.includes(v));
80
+ if (unknown !== undefined) {
81
+ errors[key] = `${field.label} contains an unknown value: ${unknown}`;
82
+ return;
83
+ }
84
+ }
85
+ if (list.length > 0)
86
+ data[key] = list;
87
+ return;
88
+ }
89
+ // image: the nested object arm, dropping the key on empty src.
90
+ if (field.type === 'image') {
91
+ coerceImage(field, key, value, data, errors);
92
+ return;
93
+ }
94
+ // Every other type is "not provided when empty" first, then coerced. `coerceToText` turns a parsed
95
+ // value into its string form BEFORE the empty check, so a real parsed value (a Date on a date or
96
+ // datetime field, a number on a number field) is not read as empty.
97
+ const text = coerceToText(field.type, value);
98
+ if (text === '') {
99
+ if (field.required)
100
+ errors[key] = `${field.label} is required`;
101
+ return;
102
+ }
103
+ switch (field.type) {
104
+ case 'number': {
105
+ const n = Number(text);
106
+ // Reject NaN and the non-finite values Number() yields for "Infinity"/"1e400", which an
107
+ // isNaN check alone would pass through and commit as a YAML .inf scalar.
108
+ if (!Number.isFinite(n))
109
+ errors[key] = `${field.label} must be a number`;
110
+ else if (field.integer && !Number.isInteger(n))
111
+ errors[key] = `${field.label} must be a whole number`;
112
+ else if (field.min != null && n < field.min)
113
+ errors[key] = `${field.label} must be at least ${field.min}`;
114
+ else if (field.max != null && n > field.max)
115
+ errors[key] = `${field.label} must be at most ${field.max}`;
116
+ else
117
+ data[key] = n;
118
+ break;
119
+ }
120
+ case 'select': {
121
+ if (!field.options.includes(text))
122
+ errors[key] = `${field.label} contains an unknown value: ${text}`;
123
+ else
124
+ data[key] = text;
125
+ break;
126
+ }
127
+ case 'url': {
128
+ if (!URL_RE.test(text))
129
+ errors[key] = `${field.label} is not a valid URL`;
130
+ else
131
+ data[key] = text;
132
+ break;
133
+ }
134
+ case 'email': {
135
+ if (!EMAIL_RE.test(text))
136
+ errors[key] = `${field.label} is not a valid email address`;
137
+ else
138
+ data[key] = text;
139
+ break;
140
+ }
141
+ case 'date': {
142
+ if (!isCalendarDate(text)) {
143
+ errors[key] = `${field.label} must be a valid date (YYYY-MM-DD)`;
144
+ break;
145
+ }
146
+ const boundsError = dateBoundsError(text, field, field.label);
147
+ if (boundsError != null) {
148
+ errors[key] = boundsError;
149
+ break;
150
+ }
151
+ data[key] = text;
152
+ break;
153
+ }
154
+ default: {
155
+ // text, textarea, datetime: a trimmed non-empty string. text and textarea also enforce the
156
+ // string-length and pattern constraints (v1 parity); datetime stays a plain string for now,
157
+ // since its bounds are out of scope this pass and v1 has no datetime equivalent to match.
158
+ if (field.type === 'text' || field.type === 'textarea') {
159
+ const lengthError = stringLengthError(text, field, field.label);
160
+ if (lengthError != null) {
161
+ errors[key] = lengthError;
162
+ break;
163
+ }
164
+ const formatError = patternError(text, patterns.get(key), field.label);
165
+ if (formatError != null) {
166
+ errors[key] = formatError;
167
+ break;
168
+ }
169
+ }
170
+ data[key] = text;
171
+ }
172
+ }
173
+ }
174
+ /**
175
+ * Build a fieldset from a key-to-descriptor record. The returned schema carries the descriptors, a
176
+ * server-derived validator that coerces per type and returns field-keyed errors or normalized data,
177
+ * and the Standard Schema conformance property whose issues map each error to a single-segment path.
178
+ */
179
+ export function fieldset(record, options = {}) {
180
+ // Compile each text/textarea pattern once at construction, so a malformed pattern fails loudly here
181
+ // (mirroring v1's compilePatterns) rather than on every save. Keyed by field name for validateField.
182
+ const patterns = new Map();
183
+ for (const [key, field] of Object.entries(record)) {
184
+ if ((field.type === 'text' || field.type === 'textarea') && field.pattern != null) {
185
+ patterns.set(key, compilePattern(field.pattern, field.label));
186
+ }
187
+ }
188
+ const validate = (frontmatter, body) => {
189
+ const data = {};
190
+ const errors = {};
191
+ for (const [key, field] of Object.entries(record)) {
192
+ validateField(key, field, frontmatter[key], data, errors, patterns);
193
+ }
194
+ if (Object.keys(errors).length > 0)
195
+ return { ok: false, errors };
196
+ const refined = options.refine?.(data, body);
197
+ return refined && Object.keys(refined).length > 0 ? { ok: false, errors: refined } : { ok: true, data };
198
+ };
199
+ const standard = {
200
+ version: 1,
201
+ vendor: 'cairn',
202
+ validate: (value) => {
203
+ const { frontmatter = {}, body = '' } = (value ?? {});
204
+ const result = validate(frontmatter ?? {}, body ?? '');
205
+ return result.ok
206
+ ? { value: result.data }
207
+ : { issues: Object.entries(result.errors).map(([key, message]) => ({ message, path: [key] })) };
208
+ },
209
+ };
210
+ return { fields: record, behavior: {}, validate, '~standard': standard };
211
+ }
212
+ /**
213
+ * Resolve each descriptor's `default` to a form-initial value, so a fresh entry opens prefilled. The
214
+ * `'today'` sentinel on a date field resolves through the passed `now` to its `YYYY-MM-DD` form; an
215
+ * empty-string or `false` default is omitted, so an untouched field commits no key (the
216
+ * minimal-frontmatter invariant). With no `now`, a `'today'` default is omitted rather than read off
217
+ * a real clock, since library code must stay deterministic and Workers-safe.
218
+ */
219
+ export function initialValues(fieldset, now) {
220
+ const values = {};
221
+ for (const [key, field] of Object.entries(fieldset.fields)) {
222
+ const value = field.default;
223
+ if (value === undefined || value === '' || value === false)
224
+ continue;
225
+ if (field.type === 'date' && value === 'today') {
226
+ if (now)
227
+ values[key] = now.toISOString().slice(0, 10);
228
+ continue;
229
+ }
230
+ values[key] = value;
231
+ }
232
+ return values;
233
+ }
@@ -1,3 +1,8 @@
1
+ // cairn-cms: the concept schema primitive (schema-source-of-truth design). One field
2
+ // declaration yields a plain-data field projection for the editor form, a generated validator,
3
+ // and an inferred frontmatter type. Plan 1 builds the additive primitive; the adapter-contract
4
+ // cutover and the typed reads are Plan 2.
5
+ import { compilePattern, dateBoundsError, patternError, stringLengthError } from './field-rules.js';
1
6
  import { validateFields } from './validate.js';
2
7
  // Enforce the declarative per-field rules on an already-coerced value. Rules run only on a
3
8
  // present, non-empty string value, so an absent optional field is never flagged. The first
@@ -6,23 +11,19 @@ function applyRules(field, value, errors, patterns) {
6
11
  if (typeof value !== 'string' || value === '')
7
12
  return;
8
13
  if (field.type === 'text' || field.type === 'textarea') {
9
- if (field.min != null && value.length < field.min)
10
- errors[field.name] = `${field.label} must be at least ${field.min} characters`;
11
- else if (field.max != null && value.length > field.max)
12
- errors[field.name] = `${field.label} must be at most ${field.max} characters`;
13
- else if (field.length != null && value.length !== field.length)
14
- errors[field.name] = `${field.label} must be exactly ${field.length} characters`;
15
- else if (field.pattern != null) {
16
- const re = patterns.get(field.name);
17
- if (re && !re.test(value))
18
- errors[field.name] = `${field.label} is not in the expected format`;
14
+ const lengthError = stringLengthError(value, field, field.label);
15
+ if (lengthError != null)
16
+ errors[field.name] = lengthError;
17
+ else {
18
+ const formatError = patternError(value, patterns.get(field.name), field.label);
19
+ if (formatError != null)
20
+ errors[field.name] = formatError;
19
21
  }
20
22
  }
21
23
  else if (field.type === 'date') {
22
- if (field.min != null && value < field.min)
23
- errors[field.name] = `${field.label} must be on or after ${field.min}`;
24
- else if (field.max != null && value > field.max)
25
- errors[field.name] = `${field.label} must be on or before ${field.max}`;
24
+ const boundsError = dateBoundsError(value, field, field.label);
25
+ if (boundsError != null)
26
+ errors[field.name] = boundsError;
26
27
  }
27
28
  }
28
29
  // Compile each declared text/textarea pattern once, so a malformed pattern fails loudly at
@@ -31,12 +32,7 @@ function compilePatterns(fields) {
31
32
  const compiled = new Map();
32
33
  for (const field of fields) {
33
34
  if ((field.type === 'text' || field.type === 'textarea') && field.pattern != null) {
34
- try {
35
- compiled.set(field.name, new RegExp(field.pattern));
36
- }
37
- catch (cause) {
38
- throw new Error(`cairn: field "${field.name}" has an invalid pattern: ${field.pattern}`, { cause });
39
- }
35
+ compiled.set(field.name, compilePattern(field.pattern, field.name));
40
36
  }
41
37
  }
42
38
  return compiled;
@@ -31,6 +31,14 @@ export interface PublicRoutesDeps {
31
31
  * media is off and no `heroImage` projection is derived.
32
32
  */
33
33
  resolveMedia?: MediaResolve;
34
+ /**
35
+ * Whether the site configured media on, read from `runtime.resolvedAssets.enabled`. It exists only
36
+ * to diagnose a forgotten wire-point: media on but no `resolveMedia` reached this factory, which
37
+ * renders public hero and body images as bare `media:` tokens. When true and `resolveMedia` is
38
+ * absent, the factory emits `media.resolver_absent` once at construction. It does not change
39
+ * resolution; `resolveMedia` alone still gates the hero projection.
40
+ */
41
+ assetsEnabled?: boolean;
34
42
  }
35
43
  /** The archive and tag list data: summaries the template renders. */
36
44
  export interface ListData {
@@ -8,9 +8,18 @@ import { buildSeoMeta } from './seo.js';
8
8
  import { readSeoFields, resolveImageUrl } from './seo-fields.js';
9
9
  import { buildLinkResolver } from './site-resolver.js';
10
10
  import { parseMediaToken } from '../media/reference.js';
11
+ import { log } from '../log/index.js';
11
12
  /** Build the public loaders for a site's unified index. */
12
13
  export function createPublicRoutes(deps) {
13
- const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia } = deps;
14
+ const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia, assetsEnabled } = deps;
15
+ // Diagnose a forgotten wire-point: media is configured on but no resolver reached this factory, so
16
+ // every public hero and body `media:` token renders bare (the ecxc 0.57.0 finding). The condition
17
+ // is a property of the wiring, not of any one load, so it is checked once here at construction
18
+ // rather than per entryLoad or per image, which keeps the warning loud-once and out of the
19
+ // prerender hot path. Resolution is unchanged; resolveMedia alone still gates the hero projection.
20
+ if (assetsEnabled && !resolveMedia) {
21
+ log.warn('media.resolver_absent', { enabled: true });
22
+ }
14
23
  /**
15
24
  * Derive the hero projection from an entry's frontmatter, without mutating it (locked decision 5).
16
25
  * The hero lives at the conventional `image` key as the validated nested object `{ src, alt, caption }`;
package/dist/index.d.ts CHANGED
@@ -10,6 +10,10 @@ export { frontmatterFromForm, dateInputValue, serializeMarkdown, parseMarkdown,
10
10
  export { defineFields } from './content/schema.js';
11
11
  export { defineAdapter } from './content/adapter.js';
12
12
  export type { ConceptSchema, Infer, InferFields, DefineFieldsOptions, StandardInput, StandardSchemaV1 } from './content/schema.js';
13
+ export { fields } from './content/fields.js';
14
+ export type { FieldDescriptor } from './content/fields.js';
15
+ export { fieldset, initialValues } from './content/fieldset.js';
16
+ export type { Fieldset, InferFieldset, FieldsetOptions, BehaviorTable } from './content/fieldset.js';
13
17
  export { isValidId, idFromFilename, filenameFromId, slugify, slugFromId, composeDatedId, } from './content/ids.js';
14
18
  export type { DatePrefix } from './content/ids.js';
15
19
  export { parseCairnToken, extractCairnLinks, formatCairnToken, escapeLinkText } from './content/links.js';
package/dist/index.js CHANGED
@@ -7,6 +7,11 @@ export { composeRuntime } from './content/compose.js';
7
7
  export { frontmatterFromForm, dateInputValue, serializeMarkdown, parseMarkdown, } from './content/frontmatter.js';
8
8
  export { defineFields } from './content/schema.js';
9
9
  export { defineAdapter } from './content/adapter.js';
10
+ // The Contract v2 field vocabulary, additive beside `defineFields`. The individual *Field
11
+ // interfaces and the bare `Infer` stay module-local: the old `FrontmatterField` model above
12
+ // already exports those names, and the cutover plan frees them.
13
+ export { fields } from './content/fields.js';
14
+ export { fieldset, initialValues } from './content/fieldset.js';
10
15
  export { isValidId, idFromFilename, filenameFromId, slugify, slugFromId, composeDatedId, } from './content/ids.js';
11
16
  // Internal-link token and the committed content manifest (content-graph design). The corpus
12
17
  // builder and the request-time resolver ship from the delivery entry; this surface is the
@@ -1 +1 @@
1
- export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'config.invalid' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'publish.address_collision' | 'github.unreachable' | 'guard.rejected' | 'media.uploaded' | 'media.upload_failed' | 'media.delivery_failed' | 'media.orphan_reconcile' | 'media.resolve_missing' | 'media.deleted' | 'media.delete_blocked' | 'media.bulk_deleted' | 'media.orphans_purged' | 'media.replaced' | 'media.replace_blocked' | 'media.alt_propagated' | 'dictionary.added' | 'dictionary.add_conflict' | 'tidy.done' | 'tidy.error' | 'tidy.refused' | 'tidy.empty';
1
+ export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'config.invalid' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'publish.address_collision' | 'github.unreachable' | 'guard.rejected' | 'media.uploaded' | 'media.upload_failed' | 'media.delivery_failed' | 'media.orphan_reconcile' | 'media.resolve_missing' | 'media.resolver_absent' | 'media.deleted' | 'media.delete_blocked' | 'media.bulk_deleted' | 'media.orphans_purged' | 'media.replaced' | 'media.replace_blocked' | 'media.alt_propagated' | 'dictionary.added' | 'dictionary.add_conflict' | 'tidy.done' | 'tidy.error' | 'tidy.refused' | 'tidy.empty';
@@ -1,5 +1,5 @@
1
1
  export { normalizeAssets, type ResolvedAssetConfig } from './config.js';
2
- export { parseMediaManifest, findByHash, upsertMediaEntry, removeMediaEntry, serializeMediaManifest, parseMediaEntries, type MediaEntry, type MediaManifest, } from './manifest.js';
2
+ export { parseMediaManifest, readCommittedManifest, findByHash, upsertMediaEntry, removeMediaEntry, serializeMediaManifest, parseMediaEntries, type MediaEntry, type MediaManifest, } from './manifest.js';
3
3
  export { hashBytes, shortHash, slugifyFilename, r2Key, publicPath } from './naming.js';
4
4
  export { presetUrl, variantUrl, type VariantSpec } from './transform-url.js';
5
5
  export { parseMediaToken, mediaToken, type MediaRef } from './reference.js';
@@ -6,7 +6,7 @@
6
6
  // delivery-route factory and `requireBucket` stay on `/sveltekit`, off this surface, so the public
7
7
  // `.d.ts` for `/media` names no kit or workers-types type.
8
8
  export { normalizeAssets } from './config.js';
9
- export { parseMediaManifest, findByHash, upsertMediaEntry, removeMediaEntry, serializeMediaManifest, parseMediaEntries, } from './manifest.js';
9
+ export { parseMediaManifest, readCommittedManifest, findByHash, upsertMediaEntry, removeMediaEntry, serializeMediaManifest, parseMediaEntries, } from './manifest.js';
10
10
  export { hashBytes, shortHash, slugifyFilename, r2Key, publicPath } from './naming.js';
11
11
  export { presetUrl, variantUrl } from './transform-url.js';
12
12
  export { parseMediaToken, mediaToken } from './reference.js';
@@ -26,6 +26,17 @@ export type MediaManifest = Record<string, MediaEntry>;
26
26
  * object is returned as the manifest.
27
27
  */
28
28
  export declare function parseMediaManifest(json: unknown): MediaManifest;
29
+ /**
30
+ * Read the committed media manifest from an `import.meta.glob` eager result, degrading a missing
31
+ * file to an empty manifest. A static import of an absent `media.json` fails the Vite build before
32
+ * any runtime degrade can run, so a fresh site with no manifest cannot build. A glob result is the
33
+ * build-safe read: `import.meta.glob` returns `{}` when nothing matches rather than throwing, and
34
+ * this helper extracts the single matched value and parses it, so a missing file reads a clean `{}`.
35
+ * @param globResult - The eager glob result for the committed manifest, an empty object when the
36
+ * file is absent. The consumer passes
37
+ * `import.meta.glob('<path-to-media.json>', { eager: true, import: 'default' })`.
38
+ */
39
+ export declare function readCommittedManifest(globResult: Record<string, unknown>): MediaManifest;
29
40
  /**
30
41
  * Parse the posted `media` field into a validated list of MediaEntry rows. The field arrives as a
31
42
  * JSON string (the usual form-post shape), an already-parsed array, or junk. A string is JSON-parsed