@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.
- package/CHANGELOG.md +134 -0
- package/dist/auth/types.d.ts +7 -0
- package/dist/components/ComponentInsertDialog.svelte +17 -6
- package/dist/components/ConceptList.svelte +25 -4
- package/dist/components/cairn-admin.css +175 -2
- package/dist/content/field-rules.d.ts +15 -0
- package/dist/content/field-rules.js +39 -0
- package/dist/content/fields.d.ts +121 -0
- package/dist/content/fields.js +30 -0
- package/dist/content/fieldset.d.ts +86 -0
- package/dist/content/fieldset.js +233 -0
- package/dist/content/schema.js +16 -20
- package/dist/delivery/public-routes.d.ts +8 -0
- package/dist/delivery/public-routes.js +10 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/manifest.d.ts +11 -0
- package/dist/media/manifest.js +13 -0
- package/dist/render/highlight.d.ts +9 -0
- package/dist/render/highlight.js +206 -0
- package/dist/render/pipeline.js +12 -1
- package/dist/render/registry.d.ts +10 -2
- package/dist/render/registry.js +21 -1
- package/dist/render/rehype-dispatch.d.ts +2 -6
- package/dist/render/rehype-dispatch.js +2 -6
- package/dist/render/sanitize-schema.d.ts +10 -0
- package/dist/render/sanitize-schema.js +29 -0
- package/dist/sveltekit/guard.js +10 -0
- package/package.json +13 -2
- package/src/lib/auth/types.ts +7 -0
- package/src/lib/components/ComponentInsertDialog.svelte +17 -6
- package/src/lib/components/ConceptList.svelte +41 -4
- package/src/lib/content/field-rules.ts +40 -0
- package/src/lib/content/fields.ts +127 -0
- package/src/lib/content/fieldset.ts +307 -0
- package/src/lib/content/schema.ts +9 -13
- package/src/lib/delivery/public-routes.ts +19 -1
- package/src/lib/index.ts +7 -0
- package/src/lib/log/events.ts +1 -0
- package/src/lib/media/index.ts +1 -0
- package/src/lib/media/manifest.ts +14 -0
- package/src/lib/render/highlight.ts +259 -0
- package/src/lib/render/pipeline.ts +12 -1
- package/src/lib/render/registry.ts +30 -3
- package/src/lib/render/rehype-dispatch.ts +2 -6
- package/src/lib/render/sanitize-schema.ts +31 -0
- 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
|
-
|
|
81
|
-
|
|
82
|
-
else
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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,
|
package/src/lib/log/events.ts
CHANGED
package/src/lib/media/index.ts
CHANGED
|
@@ -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
|