@saena-io/content 0.1.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.
@@ -0,0 +1,524 @@
1
+ import type { AssetRef } from '@saena-io/plugin-sdk';
2
+ import { z } from 'zod';
3
+ import type { InferValue } from './infer';
4
+ import { fieldType, registerFieldType } from './registry';
5
+ import type { FieldDecl, FieldType, TypedDecl } from './types';
6
+
7
+ // The built-in field types + the authoring helpers that produce typed declarations. Each built-in is ONE
8
+ // mechanism with custom types: the form engine renders `getEditor(id)` for every field, built-ins ship that
9
+ // editor, custom types register their own. This module owns the React-free behaviour (empty/validate/leaves/
10
+ // hydrate); the React editors live in ../admin. Importing this module registers the built-ins.
11
+ //
12
+ // Storage vs config: text/richtext leaves are TRANSLATABLE — their source text is synced to i18n and stripped
13
+ // from the persisted config by the service; on read, `hydrate` resolves them via `ctx.text(path)`, never from
14
+ // storage. Everything else (numbers, hrefs, discriminants, refs) is CONFIG — persisted in the JSON blob.
15
+
16
+ /** A lighter slugify for live typing — keeps repeated/trailing hyphens so word boundaries stay typeable. */
17
+ export function softSlugify(input: string): string {
18
+ return input
19
+ .toLowerCase()
20
+ .replace(/\s+/g, '-')
21
+ .replace(/[^a-z0-9-]/g, '');
22
+ }
23
+ /** Normalise to a URL slug: lowercase, separators → hyphens, strip the rest, collapse + trim hyphens. */
24
+ export function slugify(input: string): string {
25
+ return softSlugify(input)
26
+ .replace(/-+/g, '-')
27
+ .replace(/^-+|-+$/g, '');
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------------------------------------
31
+ // Leaf types
32
+ // ---------------------------------------------------------------------------------------------------------
33
+
34
+ const textType: FieldType<string, string> = {
35
+ id: 'text',
36
+ kind: 'leaf',
37
+ empty: () => '',
38
+ validate: (_d, raw) => (raw == null ? '' : z.string().parse(raw)),
39
+ leaves: (_d, s, path) => [{ path, sourceText: s ?? '', format: 'plain' }],
40
+ hydrate: (_d, _s, ctx, path) => ctx.text(path),
41
+ toConfig: () => '',
42
+ fill: (_d, _s, ctx, path) => ctx.text(path),
43
+ };
44
+
45
+ // Rich text storage is the serialized Slate-JSON string (the @saena-io/ui codec's shape); the field core keeps it
46
+ // opaque so it stays @saena-io/ui-free (the server graph ships no editor). '' is the empty doc.
47
+ const richtextType: FieldType<string, string> = {
48
+ id: 'richtext',
49
+ kind: 'leaf',
50
+ empty: () => '',
51
+ validate: (_d, raw) => (raw == null ? '' : z.string().parse(raw)),
52
+ leaves: (_d, s, path) => [{ path, sourceText: s ?? '', format: 'rich' }],
53
+ hydrate: (_d, _s, ctx, path) => ctx.text(path),
54
+ toConfig: () => '',
55
+ fill: (_d, _s, ctx, path) => ctx.text(path),
56
+ };
57
+
58
+ const numberType: FieldType<number, number> = {
59
+ id: 'number',
60
+ kind: 'leaf',
61
+ empty: () => 0,
62
+ validate: (_d, raw) => (raw == null ? 0 : z.number().parse(raw)),
63
+ leaves: () => [],
64
+ hydrate: (_d, s) => s ?? 0,
65
+ toConfig: (_d, s) => s ?? 0,
66
+ fill: (_d, s) => s ?? 0,
67
+ };
68
+
69
+ const booleanType: FieldType<boolean, boolean> = {
70
+ id: 'boolean',
71
+ kind: 'leaf',
72
+ empty: () => false,
73
+ validate: (_d, raw) => (raw == null ? false : z.boolean().parse(raw)),
74
+ leaves: () => [],
75
+ hydrate: (_d, s) => s ?? false,
76
+ toConfig: (_d, s) => s ?? false,
77
+ fill: (_d, s) => s ?? false,
78
+ };
79
+
80
+ // An ISO date string (YYYY-MM-DD or full ISO); kept as a string so it's trivially serializable.
81
+ const dateType: FieldType<string, string> = {
82
+ id: 'date',
83
+ kind: 'leaf',
84
+ empty: () => '',
85
+ validate: (_d, raw) => (raw == null ? '' : z.string().parse(raw)),
86
+ leaves: () => [],
87
+ hydrate: (_d, s) => s ?? '',
88
+ toConfig: (_d, s) => s ?? '',
89
+ fill: (_d, s) => s ?? '',
90
+ };
91
+
92
+ export interface SelectCfg {
93
+ options: string[];
94
+ }
95
+ const selectType: FieldType<string, string> = {
96
+ id: 'select',
97
+ kind: 'leaf',
98
+ empty: (d) => (d.cfg as SelectCfg | undefined)?.options?.[0] ?? '',
99
+ validate: (d, raw) => {
100
+ const options = (d.cfg as SelectCfg | undefined)?.options ?? [];
101
+ const v = raw == null ? '' : z.string().parse(raw);
102
+ return options.length === 0 || options.includes(v) ? v : (options[0] ?? '');
103
+ },
104
+ leaves: () => [],
105
+ hydrate: (_d, s) => s ?? '',
106
+ toConfig: (_d, s) => s ?? '',
107
+ fill: (_d, s) => s ?? '',
108
+ };
109
+
110
+ // A URL slug — config (locale-neutral). Normalised on save; the editor slugifies input + can auto-derive from
111
+ // a sibling field (see SlugCfg.from).
112
+ export interface SlugCfg {
113
+ /** A sibling field key to auto-derive the slug from (until the user edits it). */
114
+ from?: string;
115
+ }
116
+ const slugType: FieldType<string, string> = {
117
+ id: 'slug',
118
+ kind: 'leaf',
119
+ empty: () => '',
120
+ validate: (_d, raw) => slugify(raw == null ? '' : String(raw)),
121
+ leaves: () => [],
122
+ hydrate: (_d, s) => s ?? '',
123
+ toConfig: (_d, s) => s ?? '',
124
+ fill: (_d, s) => s ?? '',
125
+ };
126
+
127
+ // A link/CTA: href is config; label is a translatable text leaf nested at [...path, 'label'].
128
+ interface LinkValue {
129
+ href: string;
130
+ label: string;
131
+ }
132
+ const linkType: FieldType<LinkValue, LinkValue> = {
133
+ id: 'link',
134
+ kind: 'leaf',
135
+ empty: () => ({ href: '', label: '' }),
136
+ validate: (_d, raw) => {
137
+ const o = (raw ?? {}) as Partial<LinkValue>;
138
+ return { href: z.string().parse(o.href ?? ''), label: z.string().parse(o.label ?? '') };
139
+ },
140
+ leaves: (_d, s, path) => [
141
+ { path: [...path, 'label'], sourceText: s?.label ?? '', format: 'plain' },
142
+ ],
143
+ hydrate: (_d, s, ctx, path) => ({ href: s?.href ?? '', label: ctx.text([...path, 'label']) }),
144
+ toConfig: (_d, s) => ({ href: s?.href ?? '', label: '' }),
145
+ fill: (_d, s, ctx, path) => ({ href: s?.href ?? '', label: ctx.text([...path, 'label']) }),
146
+ };
147
+
148
+ // An image: `ref` is the uploaded AssetRef (config — a denormalized snapshot carrying the render URL, ADR-0002);
149
+ // `alt` is a translatable leaf; `hotspot` is the optional focal point (config — the "keep in frame" point used
150
+ // when the public component crops to a render ratio, ADR-0009). The registry (core) is the system-of-record.
151
+ /** A focal point on the original, each axis 0–1. Unset ⇒ centre (0.5, 0.5). */
152
+ interface Hotspot {
153
+ x: number;
154
+ y: number;
155
+ }
156
+ interface ImageValue {
157
+ ref: AssetRef | null;
158
+ alt: string;
159
+ hotspot?: Hotspot;
160
+ }
161
+ /** Loosely validate a stored AssetRef (own uploads are well-formed; this guards hand-edited config) → null on junk. */
162
+ const assetRefSchema = z
163
+ .object({
164
+ id: z.string(),
165
+ url: z.string(),
166
+ mime: z.string(),
167
+ size: z.number(),
168
+ width: z.number().optional(),
169
+ height: z.number().optional(),
170
+ dominantColor: z.string().optional(),
171
+ public: z.boolean(),
172
+ })
173
+ .nullable()
174
+ .catch(null);
175
+ const clamp01 = (n: number): number => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.5);
176
+ /** Clamp a hand-edited/stored hotspot into range; junk (or absent) ⇒ undefined (centre default). */
177
+ const hotspotSchema = z
178
+ .object({ x: z.number(), y: z.number() })
179
+ .transform((h) => ({ x: clamp01(h.x), y: clamp01(h.y) }))
180
+ .optional()
181
+ .catch(undefined);
182
+ const imageType: FieldType<ImageValue, ImageValue> = {
183
+ id: 'image',
184
+ kind: 'leaf',
185
+ empty: () => ({ ref: null, alt: '' }),
186
+ validate: (_d, raw) => {
187
+ const o = (raw ?? {}) as Partial<ImageValue>;
188
+ return {
189
+ ref: assetRefSchema.parse(o.ref ?? null),
190
+ alt: z.string().parse(o.alt ?? ''),
191
+ hotspot: hotspotSchema.parse(o.hotspot),
192
+ };
193
+ },
194
+ leaves: (_d, s, path) => [{ path: [...path, 'alt'], sourceText: s?.alt ?? '', format: 'plain' }],
195
+ hydrate: (_d, s, ctx, path) => ({
196
+ ref: s?.ref ?? null,
197
+ alt: ctx.text([...path, 'alt']),
198
+ hotspot: s?.hotspot,
199
+ }),
200
+ toConfig: (_d, s) => ({ ref: s?.ref ?? null, alt: '', hotspot: s?.hotspot }),
201
+ fill: (_d, s, ctx, path) => ({
202
+ ref: s?.ref ?? null,
203
+ alt: ctx.text([...path, 'alt']),
204
+ hotspot: s?.hotspot,
205
+ }),
206
+ };
207
+
208
+ // ---------------------------------------------------------------------------------------------------------
209
+ // Composite types — recurse into children via the registry
210
+ // ---------------------------------------------------------------------------------------------------------
211
+
212
+ interface ObjectCfg {
213
+ children: Record<string, FieldDecl>;
214
+ }
215
+ type ObjStorage = Record<string, unknown>;
216
+ const objectType: FieldType<ObjStorage, ObjStorage> = {
217
+ id: 'object',
218
+ kind: 'object',
219
+ empty: (d) => {
220
+ const { children } = d.cfg as ObjectCfg;
221
+ const out: ObjStorage = {};
222
+ for (const [k, child] of Object.entries(children)) out[k] = fieldType(child.type).empty(child);
223
+ return out;
224
+ },
225
+ validate: (d, raw) => {
226
+ const { children } = d.cfg as ObjectCfg;
227
+ const src = (raw ?? {}) as ObjStorage;
228
+ const out: ObjStorage = {};
229
+ for (const [k, child] of Object.entries(children))
230
+ out[k] = fieldType(child.type).validate(child, src[k]);
231
+ return out;
232
+ },
233
+ leaves: (d, s, path) => {
234
+ const { children } = d.cfg as ObjectCfg;
235
+ const src = (s ?? {}) as ObjStorage;
236
+ return Object.entries(children).flatMap(([k, child]) =>
237
+ fieldType(child.type).leaves(child, src[k], [...path, k]),
238
+ );
239
+ },
240
+ hydrate: (d, s, ctx, path) => {
241
+ const { children } = d.cfg as ObjectCfg;
242
+ const src = (s ?? {}) as ObjStorage;
243
+ const out: ObjStorage = {};
244
+ for (const [k, child] of Object.entries(children))
245
+ out[k] = fieldType(child.type).hydrate(child, src[k], ctx, [...path, k]);
246
+ return out;
247
+ },
248
+ toConfig: (d, s) => {
249
+ const { children } = d.cfg as ObjectCfg;
250
+ const src = (s ?? {}) as ObjStorage;
251
+ const out: ObjStorage = {};
252
+ for (const [k, child] of Object.entries(children))
253
+ out[k] = fieldType(child.type).toConfig(child, src[k]);
254
+ return out;
255
+ },
256
+ fill: (d, s, ctx, path) => {
257
+ const { children } = d.cfg as ObjectCfg;
258
+ const src = (s ?? {}) as ObjStorage;
259
+ const out: ObjStorage = {};
260
+ for (const [k, child] of Object.entries(children))
261
+ out[k] = fieldType(child.type).fill(child, src[k], ctx, [...path, k]);
262
+ return out;
263
+ },
264
+ };
265
+
266
+ interface ListCfg {
267
+ item: FieldDecl;
268
+ /** Derives the label shown on a collapsed item card in the admin (e.g. a program's title). Admin-only and
269
+ * ignored by the React-free core; if omitted the admin falls back to a title/name/label heuristic. NOTE: it
270
+ * receives the item's EDITOR (storage) value — for shallow text/object/union fields this matches the public
271
+ * shape, but a field reached THROUGH a nested list appears as `{ items: [...] }`, not a flat array. */
272
+ itemTitle?: (value: unknown) => string;
273
+ }
274
+ interface ListItem {
275
+ _id: string;
276
+ value: unknown;
277
+ }
278
+ interface ListStorage {
279
+ items: ListItem[];
280
+ }
281
+ const listType: FieldType<ListStorage, unknown[]> = {
282
+ id: 'list',
283
+ kind: 'list',
284
+ empty: () => ({ items: [] }),
285
+ validate: (d, raw) => {
286
+ const { item } = d.cfg as ListCfg;
287
+ const items = Array.isArray((raw as ListStorage | undefined)?.items)
288
+ ? (raw as ListStorage).items
289
+ : [];
290
+ return {
291
+ items: items.map((it) => ({
292
+ _id: String(it?._id ?? ''),
293
+ value: fieldType(item.type).validate(item, it?.value),
294
+ })),
295
+ };
296
+ },
297
+ leaves: (d, s, path) => {
298
+ const { item } = d.cfg as ListCfg;
299
+ return (s?.items ?? []).flatMap((it) =>
300
+ fieldType(item.type).leaves(item, it.value, [...path, it._id]),
301
+ );
302
+ },
303
+ hydrate: (d, s, ctx, path) => {
304
+ const { item } = d.cfg as ListCfg;
305
+ return (s?.items ?? []).map((it) =>
306
+ fieldType(item.type).hydrate(item, it.value, ctx, [...path, it._id]),
307
+ );
308
+ },
309
+ toConfig: (d, s) => {
310
+ const { item } = d.cfg as ListCfg;
311
+ return {
312
+ items: (s?.items ?? []).map((it) => ({
313
+ _id: it._id,
314
+ value: fieldType(item.type).toConfig(item, it.value),
315
+ })),
316
+ };
317
+ },
318
+ fill: (d, s, ctx, path) => {
319
+ const { item } = d.cfg as ListCfg;
320
+ return {
321
+ items: (s?.items ?? []).map((it) => ({
322
+ _id: it._id,
323
+ value: fieldType(item.type).fill(item, it.value, ctx, [...path, it._id]),
324
+ })),
325
+ };
326
+ },
327
+ };
328
+
329
+ interface UnionCfg {
330
+ discriminant: string;
331
+ variants: Record<string, FieldDecl>;
332
+ }
333
+ type UnionStorage = Record<string, unknown>;
334
+ // A discriminated union: storage is the discriminant flat-merged with the active variant's (object) storage.
335
+ // Variants MUST be object declarations, so the merge + the recursive walk are uniform (the variant's object
336
+ // type reads only its own child keys; the extra discriminant key is ignored). Switching variants reuses the
337
+ // same path, so abandoned keys are pruned by prefix on save.
338
+ const unionType: FieldType<UnionStorage, UnionStorage> = {
339
+ id: 'union',
340
+ kind: 'union',
341
+ empty: (d) => {
342
+ const { discriminant, variants } = d.cfg as UnionCfg;
343
+ const first = Object.keys(variants)[0];
344
+ if (!first) return {};
345
+ const variant = variants[first] as FieldDecl;
346
+ return { [discriminant]: first, ...(fieldType(variant.type).empty(variant) as ObjStorage) };
347
+ },
348
+ validate: (d, raw) => {
349
+ const { discriminant, variants } = d.cfg as UnionCfg;
350
+ const keys = Object.keys(variants);
351
+ const given = (raw as UnionStorage | undefined)?.[discriminant];
352
+ const vid = typeof given === 'string' && variants[given] ? given : (keys[0] ?? '');
353
+ const variant = variants[vid];
354
+ if (!variant) return {};
355
+ return {
356
+ [discriminant]: vid,
357
+ ...(fieldType(variant.type).validate(variant, raw) as ObjStorage),
358
+ };
359
+ },
360
+ leaves: (d, s, path) => {
361
+ const { discriminant, variants } = d.cfg as UnionCfg;
362
+ const vid = s?.[discriminant];
363
+ const variant = typeof vid === 'string' ? variants[vid] : undefined;
364
+ return variant ? fieldType(variant.type).leaves(variant, s, path) : [];
365
+ },
366
+ hydrate: (d, s, ctx, path) => {
367
+ const { discriminant, variants } = d.cfg as UnionCfg;
368
+ const vid = s?.[discriminant];
369
+ const variant = typeof vid === 'string' ? variants[vid] : undefined;
370
+ if (!variant) return { [discriminant]: vid };
371
+ return {
372
+ [discriminant]: vid,
373
+ ...(fieldType(variant.type).hydrate(variant, s, ctx, path) as ObjStorage),
374
+ };
375
+ },
376
+ toConfig: (d, s) => {
377
+ const { discriminant, variants } = d.cfg as UnionCfg;
378
+ const vid = s?.[discriminant];
379
+ const variant = typeof vid === 'string' ? variants[vid] : undefined;
380
+ if (!variant) return { [discriminant]: vid };
381
+ return { [discriminant]: vid, ...(fieldType(variant.type).toConfig(variant, s) as ObjStorage) };
382
+ },
383
+ fill: (d, s, ctx, path) => {
384
+ const { discriminant, variants } = d.cfg as UnionCfg;
385
+ const vid = s?.[discriminant];
386
+ const variant = typeof vid === 'string' ? variants[vid] : undefined;
387
+ if (!variant) return { [discriminant]: vid };
388
+ return {
389
+ [discriminant]: vid,
390
+ ...(fieldType(variant.type).fill(variant, s, ctx, path) as ObjStorage),
391
+ };
392
+ },
393
+ };
394
+
395
+ // Register all built-ins on import.
396
+ for (const t of [
397
+ textType,
398
+ richtextType,
399
+ numberType,
400
+ booleanType,
401
+ dateType,
402
+ selectType,
403
+ slugType,
404
+ linkType,
405
+ imageType,
406
+ objectType,
407
+ listType,
408
+ unionType,
409
+ ]) {
410
+ registerFieldType(t);
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------------------------------------
414
+ // Authoring helpers — produce typed declarations so InferValue recovers the public value type
415
+ // ---------------------------------------------------------------------------------------------------------
416
+
417
+ type Visible = (siblings: Record<string, unknown>) => boolean;
418
+ interface LeafOpts {
419
+ label?: string;
420
+ visible?: Visible;
421
+ }
422
+
423
+ export function text(opts?: LeafOpts): TypedDecl<string> {
424
+ return { type: 'text', label: opts?.label, visible: opts?.visible };
425
+ }
426
+ export function richtext(opts?: LeafOpts): TypedDecl<string> {
427
+ return { type: 'richtext', label: opts?.label, visible: opts?.visible };
428
+ }
429
+ export function number(opts?: LeafOpts): TypedDecl<number> {
430
+ return { type: 'number', label: opts?.label, visible: opts?.visible };
431
+ }
432
+ export function boolean(opts?: LeafOpts): TypedDecl<boolean> {
433
+ return { type: 'boolean', label: opts?.label, visible: opts?.visible };
434
+ }
435
+ export function date(opts?: LeafOpts): TypedDecl<string> {
436
+ return { type: 'date', label: opts?.label, visible: opts?.visible };
437
+ }
438
+ export function select<const O extends string>(
439
+ options: readonly O[],
440
+ opts?: LeafOpts,
441
+ ): TypedDecl<O, SelectCfg> {
442
+ return {
443
+ type: 'select',
444
+ label: opts?.label,
445
+ visible: opts?.visible,
446
+ cfg: { options: [...options] },
447
+ };
448
+ }
449
+ export function slug(opts?: LeafOpts & { from?: string }): TypedDecl<string, SlugCfg> {
450
+ return { type: 'slug', label: opts?.label, visible: opts?.visible, cfg: { from: opts?.from } };
451
+ }
452
+ export function link(opts?: LeafOpts): TypedDecl<LinkValue> {
453
+ return { type: 'link', label: opts?.label, visible: opts?.visible };
454
+ }
455
+ /** `ratio` (w/h) is an editor-only crop **guide** shown in the focal-point picker; the public component still
456
+ * owns the real render ratio(s) (ADR-0009). The cfg type is inline (anonymous) so the inferred schema type
457
+ * stays portable across the package boundary. */
458
+ export function image(
459
+ opts?: LeafOpts & { ratio?: number },
460
+ ): TypedDecl<ImageValue, { ratio?: number }> {
461
+ return {
462
+ type: 'image',
463
+ label: opts?.label,
464
+ visible: opts?.visible,
465
+ cfg: { ratio: opts?.ratio },
466
+ };
467
+ }
468
+
469
+ export function object<C extends Record<string, TypedDecl<unknown>>>(
470
+ children: C,
471
+ opts?: LeafOpts,
472
+ ): TypedDecl<{ [K in keyof C]: InferValue<C[K]> }, { children: C }> {
473
+ return { type: 'object', label: opts?.label, visible: opts?.visible, cfg: { children } };
474
+ }
475
+
476
+ /** Like `object`, but rendered as a visual group (a muted, rounded box) to show related fields belong
477
+ * together. Same data shape as `object` — the fields nest under this key. */
478
+ export function group<C extends Record<string, TypedDecl<unknown>>>(
479
+ children: C,
480
+ opts?: LeafOpts,
481
+ ): TypedDecl<{ [K in keyof C]: InferValue<C[K]> }, { children: C; group: true }> {
482
+ return {
483
+ type: 'object',
484
+ label: opts?.label,
485
+ visible: opts?.visible,
486
+ cfg: { children, group: true },
487
+ };
488
+ }
489
+
490
+ // `itemTitle` is typed against the item's value `V` for ergonomics (the common case — a shallow text/object/
491
+ // union title — matches the editor shape it's actually called with). The exception is a title reached through a
492
+ // NESTED list, which appears as `{ items: [...] }` at runtime; see ListCfg.itemTitle.
493
+ export function list<V>(
494
+ item: TypedDecl<V>,
495
+ opts?: LeafOpts & { itemTitle?: (value: V) => string },
496
+ ): TypedDecl<V[], { item: TypedDecl<V>; itemTitle?: (value: V) => string }> {
497
+ return {
498
+ type: 'list',
499
+ label: opts?.label,
500
+ visible: opts?.visible,
501
+ cfg: { item, itemTitle: opts?.itemTitle },
502
+ };
503
+ }
504
+
505
+ export function union<
506
+ D extends string,
507
+ V extends Record<string, TypedDecl<Record<string, unknown>>>,
508
+ >(
509
+ discriminant: D,
510
+ variants: V,
511
+ opts?: LeafOpts,
512
+ ): TypedDecl<
513
+ { [K in keyof V]: { [P in D]: K } & InferValue<V[K]> }[keyof V],
514
+ { discriminant: D; variants: V }
515
+ > {
516
+ return {
517
+ type: 'union',
518
+ label: opts?.label,
519
+ visible: opts?.visible,
520
+ cfg: { discriminant, variants },
521
+ };
522
+ }
523
+
524
+ export type { LinkValue, ImageValue };