@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,292 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest';
2
+ import {
3
+ type FieldType,
4
+ type InferValue,
5
+ type TypedDecl,
6
+ date,
7
+ fieldToConfig,
8
+ fieldValidate,
9
+ group,
10
+ hydrateSection,
11
+ image,
12
+ link,
13
+ list,
14
+ number,
15
+ object,
16
+ registerFieldType,
17
+ sectionLeaves,
18
+ select,
19
+ slug,
20
+ slugify,
21
+ softSlugify,
22
+ text,
23
+ union,
24
+ } from './index';
25
+
26
+ // A custom, config-only field type (the climbing weekly-grid stand-in) — proves custom types compose with the
27
+ // built-ins through the same registry + recursion, getting validate/leaves/toConfig participation for free.
28
+ interface WeeklyGrid {
29
+ slots: { day: number; start: string; end: string }[];
30
+ }
31
+ const weeklyType: FieldType<WeeklyGrid, WeeklyGrid> = {
32
+ id: 'test.weekly',
33
+ kind: 'leaf',
34
+ empty: () => ({ slots: [] }),
35
+ validate: (_d, raw) => ({
36
+ slots: Array.isArray((raw as WeeklyGrid)?.slots) ? (raw as WeeklyGrid).slots : [],
37
+ }),
38
+ leaves: () => [],
39
+ hydrate: (_d, s) => s ?? { slots: [] },
40
+ toConfig: (_d, s) => s ?? { slots: [] },
41
+ fill: (_d, s) => s ?? { slots: [] },
42
+ };
43
+ registerFieldType(weeklyType);
44
+ const weekly = (): TypedDecl<WeeklyGrid> => ({ type: 'test.weekly' });
45
+
46
+ // The climbing "Programs" schema: a collection of programs, each a discriminated union by `type`; the club
47
+ // variant's `schedule` is itself a union by `mode`, whose `weekly` variant embeds the custom field.
48
+ const programs = list(
49
+ union('type', {
50
+ competition: object({ title: text(), date: date(), rounds: number() }),
51
+ club: object({
52
+ title: text(),
53
+ schedule: union('mode', {
54
+ oneOff: object({ date: date(), time: text() }),
55
+ weekly: object({ grid: weekly() }),
56
+ booking: object({ provider: select(['internal', 'external']), url: link() }),
57
+ }),
58
+ }),
59
+ }),
60
+ );
61
+
62
+ const PREFIX = 'content.home.programs';
63
+
64
+ const payload = {
65
+ items: [
66
+ {
67
+ _id: 'a',
68
+ value: { type: 'competition', title: 'Bouldering Cup', date: '2026-07-01', rounds: 3 },
69
+ },
70
+ {
71
+ _id: 'b',
72
+ value: {
73
+ type: 'club',
74
+ title: 'Youth Club',
75
+ schedule: { mode: 'weekly', grid: { slots: [{ day: 1, start: '17:00', end: '18:30' }] } },
76
+ },
77
+ },
78
+ {
79
+ _id: 'c',
80
+ value: {
81
+ type: 'club',
82
+ title: 'Adults Club',
83
+ schedule: {
84
+ mode: 'booking',
85
+ provider: 'external',
86
+ url: { href: 'https://book.example', label: 'Book now' },
87
+ },
88
+ },
89
+ },
90
+ ],
91
+ };
92
+
93
+ describe('field core — climbing Programs schema', () => {
94
+ const storage = fieldValidate(programs, payload);
95
+
96
+ it('extracts translatable leaves with structural i18n keys (item ids, nested union variant)', () => {
97
+ const keys = sectionLeaves(programs, storage, PREFIX)
98
+ .map((l) => l.key)
99
+ .sort();
100
+ expect(keys).toEqual(
101
+ [
102
+ 'content.home.programs.a.title',
103
+ 'content.home.programs.b.title',
104
+ 'content.home.programs.c.title',
105
+ 'content.home.programs.c.schedule.url.label',
106
+ ].sort(),
107
+ );
108
+ // The url label is a `plain` leaf, never the href (config).
109
+ const label = sectionLeaves(programs, storage, PREFIX).find((l) =>
110
+ l.key.endsWith('.url.label'),
111
+ );
112
+ expect(label).toMatchObject({ sourceText: 'Book now', format: 'plain' });
113
+ });
114
+
115
+ it('hydrates config-through + resolves translatable leaves via the localized map', () => {
116
+ const localized = Object.fromEntries(
117
+ sectionLeaves(programs, storage, PREFIX).map((l) => [l.key, `${l.sourceText} [cs]`]),
118
+ );
119
+ const value = hydrateSection(programs, storage, localized, PREFIX) as InferValue<
120
+ typeof programs
121
+ >;
122
+
123
+ expect(value).toHaveLength(3);
124
+ const first = value[0];
125
+ expect(first).toMatchObject({ type: 'competition', title: 'Bouldering Cup [cs]', rounds: 3 });
126
+ const booking = value[2];
127
+ if (booking?.type === 'club' && booking.schedule.mode === 'booking') {
128
+ expect(booking.schedule.url).toEqual({
129
+ href: 'https://book.example',
130
+ label: 'Book now [cs]',
131
+ });
132
+ expect(booking.schedule.provider).toBe('external');
133
+ } else {
134
+ throw new Error('expected a club/booking program');
135
+ }
136
+ });
137
+
138
+ it('projects config with translatable leaves blanked but structure + config preserved', () => {
139
+ const config = fieldToConfig(programs, storage) as typeof storage;
140
+ const items = (config as { items: { _id: string; value: Record<string, unknown> }[] }).items;
141
+ expect(items.map((i) => i._id)).toEqual(['a', 'b', 'c']);
142
+ // text leaf blanked, config kept
143
+ expect(items[0]?.value).toMatchObject({
144
+ type: 'competition',
145
+ title: '',
146
+ date: '2026-07-01',
147
+ rounds: 3,
148
+ });
149
+ // nested union: href kept (config), label blanked (translatable)
150
+ const sched = items[2]?.value.schedule as {
151
+ mode: string;
152
+ url: { href: string; label: string };
153
+ };
154
+ expect(sched).toMatchObject({
155
+ mode: 'booking',
156
+ url: { href: 'https://book.example', label: '' },
157
+ });
158
+ });
159
+
160
+ it('infers a discriminated-union value type that narrows by discriminant', () => {
161
+ type Programs = InferValue<typeof programs>;
162
+ expectTypeOf<Programs>().toBeArray();
163
+ const sample = [] as Programs;
164
+ for (const p of sample) {
165
+ if (p.type === 'competition') {
166
+ expectTypeOf(p.rounds).toBeNumber();
167
+ expectTypeOf(p.title).toBeString();
168
+ } else {
169
+ // club
170
+ if (p.schedule.mode === 'weekly') expectTypeOf(p.schedule.grid).toEqualTypeOf<WeeklyGrid>();
171
+ if (p.schedule.mode === 'booking')
172
+ expectTypeOf(p.schedule.provider).toEqualTypeOf<'internal' | 'external'>();
173
+ }
174
+ }
175
+ });
176
+ });
177
+
178
+ describe('field core — slug + group', () => {
179
+ it('slugify normalizes fully; softSlugify keeps a typed-in separator (typeable while editing)', () => {
180
+ expect(slugify('Summer Climbing Camp 2026!')).toBe('summer-climbing-camp-2026');
181
+ expect(softSlugify('Hello ')).toBe('hello-'); // trailing separator survives mid-type
182
+ expect(slugify('Hello ')).toBe('hello'); // …and is tidied on the full pass
183
+ });
184
+
185
+ it('slug is a config leaf — slugified storage, never a translatable i18n key', () => {
186
+ const s = object({ slug: slug({ from: 'title' }), title: text() });
187
+ const storage = fieldValidate(s, { slug: 'My Title!', title: 'My Title' });
188
+ expect((storage as { slug: string }).slug).toBe('my-title');
189
+ // only the text leaf is translatable; the slug stays in config
190
+ expect(sectionLeaves(s, storage, PREFIX).map((l) => l.key)).toEqual([
191
+ 'content.home.programs.title',
192
+ ]);
193
+ const config = fieldToConfig(s, storage) as { slug: string; title: string };
194
+ expect(config.slug).toBe('my-title'); // slug survives in config
195
+ expect(config.title).toBe(''); // text leaf blanked
196
+ });
197
+
198
+ it('image: hotspot is config (clamped, survives toConfig/hydrate); alt is the only translatable leaf', () => {
199
+ const ref = {
200
+ id: 'asset-1',
201
+ url: '/api/assets/asset-1',
202
+ mime: 'image/png',
203
+ size: 10,
204
+ width: 640,
205
+ height: 400,
206
+ public: true,
207
+ };
208
+ const s = object({ pic: image() });
209
+ // out-of-range hotspot clamps to [0,1]; alt is kept for validation
210
+ const storage = fieldValidate(s, {
211
+ pic: { ref, alt: 'A cat', hotspot: { x: 1.4, y: -0.2 } },
212
+ }) as { pic: { ref: typeof ref; alt: string; hotspot?: { x: number; y: number } } };
213
+ expect(storage.pic.hotspot).toEqual({ x: 1, y: 0 });
214
+
215
+ // only alt is a translatable leaf — ref + hotspot are config, never i18n keys
216
+ expect(sectionLeaves(s, storage, PREFIX).map((l) => l.key)).toEqual([
217
+ 'content.home.programs.pic.alt',
218
+ ]);
219
+
220
+ // config blanks alt but preserves ref + hotspot
221
+ const config = fieldToConfig(s, storage) as typeof storage;
222
+ expect(config.pic).toMatchObject({ ref, alt: '', hotspot: { x: 1, y: 0 } });
223
+
224
+ // hydrate restores the hotspot (config) and resolves alt from the localized map
225
+ const localized = { 'content.home.programs.pic.alt': 'A cat [cs]' };
226
+ const value = hydrateSection(s, storage, localized, PREFIX) as {
227
+ pic: { ref: typeof ref; alt: string; hotspot?: { x: number; y: number } };
228
+ };
229
+ expect(value.pic).toMatchObject({ ref, alt: 'A cat [cs]', hotspot: { x: 1, y: 0 } });
230
+ });
231
+
232
+ it('image: an absent or junk hotspot becomes undefined (centre default)', () => {
233
+ const s = object({ pic: image() });
234
+ const noHotspot = fieldValidate(s, { pic: { ref: null, alt: '' } }) as {
235
+ pic: { hotspot?: unknown };
236
+ };
237
+ expect(noHotspot.pic.hotspot).toBeUndefined();
238
+ const junk = fieldValidate(s, { pic: { ref: null, alt: '', hotspot: { x: 'nope' } } }) as {
239
+ pic: { hotspot?: unknown };
240
+ };
241
+ expect(junk.pic.hotspot).toBeUndefined();
242
+ });
243
+
244
+ it('group recurses like object, keying children under the group key', () => {
245
+ const s = object({ contact: group({ email: text(), phone: text() }, { label: 'Contact' }) });
246
+ const storage = fieldValidate(s, { contact: { email: 'a@b.c', phone: '123' } });
247
+ expect(
248
+ sectionLeaves(s, storage, PREFIX)
249
+ .map((l) => l.key)
250
+ .sort(),
251
+ ).toEqual(['content.home.programs.contact.email', 'content.home.programs.contact.phone']);
252
+ const localized = Object.fromEntries(
253
+ sectionLeaves(s, storage, PREFIX).map((l) => [l.key, `${l.sourceText}!`]),
254
+ );
255
+ const value = hydrateSection(s, storage, localized, PREFIX) as {
256
+ contact: { email: string; phone: string };
257
+ };
258
+ expect(value.contact).toEqual({ email: 'a@b.c!', phone: '123!' });
259
+ });
260
+ });
261
+
262
+ describe('collection key root (ADR-0011)', () => {
263
+ // A routed-collection entry roots its i18n keys at `content.collection.<collection>.<itemId>` — the STABLE
264
+ // row id, never the slug or position. The service builds that prefix; here we lock the property the design
265
+ // depends on: an entry's keys are namespaced by its id, so two entries never collide, and a key never embeds
266
+ // the slug (so renaming the slug can't re-key or orphan translations).
267
+ const entry = object({ title: text(), body: text() });
268
+ const storage = fieldValidate(entry, { title: 'Hello', body: 'World' });
269
+ const keysFor = (id: string) =>
270
+ sectionLeaves(entry, storage, `content.collection.news.${id}`)
271
+ .map((l) => l.key)
272
+ .sort();
273
+
274
+ it('roots every leaf at content.collection.<collection>.<itemId>', () => {
275
+ expect(keysFor('item-a')).toEqual([
276
+ 'content.collection.news.item-a.body',
277
+ 'content.collection.news.item-a.title',
278
+ ]);
279
+ });
280
+
281
+ it('namespaces entries by id — two entries never share a key', () => {
282
+ const a = new Set(keysFor('item-a'));
283
+ const overlap = keysFor('item-b').filter((k) => a.has(k));
284
+ expect(overlap).toEqual([]);
285
+ });
286
+
287
+ it('keys depend only on the id, not the slug (renaming a slug cannot re-key)', () => {
288
+ // Same id ⇒ identical keys regardless of any slug the entry might carry.
289
+ expect(keysFor('fixed-id')).toEqual(keysFor('fixed-id'));
290
+ expect(keysFor('fixed-id').every((k) => k.includes('.fixed-id.'))).toBe(true);
291
+ });
292
+ });
@@ -0,0 +1,49 @@
1
+ // The field-type core barrel (React-free). Importing it registers the built-in field types (./builtins runs
2
+ // its registration on load). Section authors import the helpers (text/richtext/object/list/union/…) and the
3
+ // `InferValue`/`SectionContent` types from here; the server service imports the registry + key helpers.
4
+
5
+ export type {
6
+ FieldDecl,
7
+ FieldKind,
8
+ FieldPath,
9
+ FieldType,
10
+ HydrateCtx,
11
+ TranslatableLeaf,
12
+ TypedDecl,
13
+ } from './types';
14
+ export {
15
+ fieldType,
16
+ hasFieldType,
17
+ registerFieldType,
18
+ fieldEmpty,
19
+ fieldValidate,
20
+ fieldToConfig,
21
+ } from './registry';
22
+ export {
23
+ text,
24
+ richtext,
25
+ number,
26
+ boolean,
27
+ date,
28
+ select,
29
+ slug,
30
+ link,
31
+ image,
32
+ object,
33
+ group,
34
+ list,
35
+ union,
36
+ slugify,
37
+ softSlugify,
38
+ } from './builtins';
39
+ export type { LinkValue, ImageValue, SelectCfg, SlugCfg } from './builtins';
40
+ export {
41
+ joinKey,
42
+ sectionLeaves,
43
+ hydrateCtxFrom,
44
+ hydrateSection,
45
+ hydratePreview,
46
+ fillSection,
47
+ } from './keys';
48
+ export type { SectionLeaf } from './keys';
49
+ export type { InferValue, SectionContent } from './infer';
@@ -0,0 +1,10 @@
1
+ import type { TypedDecl } from './types';
2
+
3
+ // Type-level: recover the public value type from a field declaration tree, so a section's public component is
4
+ // typed exactly (and a union narrows by its discriminant). The authoring helpers in ./builtins compute the
5
+ // phantom `__value`; this just reads it. `SectionContent<S>` is the alias a section component prop uses.
6
+
7
+ export type InferValue<D> = D extends TypedDecl<infer V, infer _Cfg> ? V : never;
8
+
9
+ /** The fully-typed content a section's public component receives, derived from its schema declaration. */
10
+ export type SectionContent<S> = InferValue<S>;
@@ -0,0 +1,69 @@
1
+ import { fieldType, fieldValidate } from './registry';
2
+ import type { FieldDecl, FieldPath, HydrateCtx } from './types';
3
+
4
+ // Structural i18n key scheme + the pure save/load helpers that join the field core to the content store.
5
+ // A section's keys are `content.<page>.<section>` + the relative field path (object keys, list item ids, the
6
+ // active union variant's sub-path). Segments never contain '.', so a dotted join is unambiguous (item ids are
7
+ // nanoids, child keys are identifiers).
8
+
9
+ const SEP = '.';
10
+
11
+ /** Join a section prefix (e.g. `content.home.hero`) with a relative field path into an absolute i18n key. */
12
+ export function joinKey(prefix: string, path: FieldPath): string {
13
+ return path.length ? `${prefix}${SEP}${path.join(SEP)}` : prefix;
14
+ }
15
+
16
+ export interface SectionLeaf {
17
+ key: string;
18
+ sourceText: string;
19
+ format: 'plain' | 'rich';
20
+ }
21
+
22
+ /** Every translatable leaf of a section value, as absolute i18n keys — for `syncTranslatable` (save) and to
23
+ * collect the keys to `localizeMany` (load). `prefix` = `content.<page>.<section>`. */
24
+ export function sectionLeaves(decl: FieldDecl, storage: unknown, prefix: string): SectionLeaf[] {
25
+ return fieldType(decl.type)
26
+ .leaves(decl, storage, [])
27
+ .map((l) => ({ key: joinKey(prefix, l.path), sourceText: l.sourceText, format: l.format }));
28
+ }
29
+
30
+ /** Build a HydrateCtx from a localized (key → text) map for a section prefix. */
31
+ export function hydrateCtxFrom(localized: Record<string, string>, prefix: string): HydrateCtx {
32
+ return { text: (path) => localized[joinKey(prefix, path)] ?? '' };
33
+ }
34
+
35
+ /** Hydrate a section's stored config into its typed public value, given localized text. */
36
+ export function hydrateSection(
37
+ decl: FieldDecl,
38
+ config: unknown,
39
+ localized: Record<string, string>,
40
+ prefix: string,
41
+ ): unknown {
42
+ return fieldType(decl.type).hydrate(decl, config, hydrateCtxFrom(localized, prefix), []);
43
+ }
44
+
45
+ /** Hydrate the admin editor's in-memory value (the FILL shape — text inline, lists as `{ items }`) straight
46
+ * into the public value shape, with no server round-trip and no localized map: the translatable leaves are
47
+ * read back out of the same value (via `sectionLeaves`) and fed to `hydrate`. Powers the live preview.
48
+ *
49
+ * Runs the same `validate` the save path runs first, so the preview shows the public render of what WILL be
50
+ * saved — e.g. a slug previews normalized, not its soft mid-typing value. The prefix is internal + arbitrary
51
+ * (it cancels out). */
52
+ export function hydratePreview(decl: FieldDecl, editorValue: unknown): unknown {
53
+ const PREFIX = 'preview';
54
+ const storage = fieldValidate(decl, editorValue);
55
+ const localized: Record<string, string> = {};
56
+ for (const leaf of sectionLeaves(decl, storage, PREFIX)) localized[leaf.key] = leaf.sourceText;
57
+ return hydrateSection(decl, storage, localized, PREFIX);
58
+ }
59
+
60
+ /** Fill a section's stored config with localized source text, preserving storage shape — the admin editor's
61
+ * load (so list ids + i18n keys round-trip on save). */
62
+ export function fillSection(
63
+ decl: FieldDecl,
64
+ config: unknown,
65
+ localized: Record<string, string>,
66
+ prefix: string,
67
+ ): unknown {
68
+ return fieldType(decl.type).fill(decl, config, hydrateCtxFrom(localized, prefix), []);
69
+ }
@@ -0,0 +1,32 @@
1
+ import type { FieldDecl, FieldType } from './types';
2
+
3
+ // The React-free field-type registry: id → runtime behaviour (empty/validate/leaves/hydrate). Built-ins
4
+ // register on import (./builtins); custom types register the same way. Composite types resolve their children
5
+ // through `fieldType(id)`, so the whole schema tree is traversable from any value. The React editor registry
6
+ // (id → AdminEditor) is a separate, parallel map in ../admin — same id, different world.
7
+
8
+ const registry = new Map<string, FieldType>();
9
+
10
+ /** Register a field type's runtime behaviour. Idempotent by id (last registration wins). */
11
+ export function registerFieldType(type: FieldType): void {
12
+ registry.set(type.id, type);
13
+ }
14
+
15
+ /** Resolve a field type by id. Throws if unknown (a schema referenced an unregistered type). */
16
+ export function fieldType(id: string): FieldType {
17
+ const type = registry.get(id);
18
+ if (!type) throw new Error(`unknown field type: ${id}`);
19
+ return type;
20
+ }
21
+
22
+ /** Whether a field type id is registered. */
23
+ export function hasFieldType(id: string): boolean {
24
+ return registry.has(id);
25
+ }
26
+
27
+ // Convenience pass-throughs that resolve the type from a declaration — used by composites + the service.
28
+ export const fieldEmpty = (decl: FieldDecl): unknown => fieldType(decl.type).empty(decl);
29
+ export const fieldValidate = (decl: FieldDecl, raw: unknown): unknown =>
30
+ fieldType(decl.type).validate(decl, raw);
31
+ export const fieldToConfig = (decl: FieldDecl, storage: unknown): unknown =>
32
+ fieldType(decl.type).toConfig(decl, storage);
@@ -0,0 +1,83 @@
1
+ // The field-type contract — the unit of composition for the content framework (ADR-0008). A FIELD is the
2
+ // atom: the dev declares a section's fields once, and that single declaration drives the admin editor, the
3
+ // public value type, validation, and how translatable text maps onto i18n keys. This module is React-free
4
+ // (the server validates/persists and the public component types its content from here); the React editors
5
+ // live in ../admin and the public readers in ../public, bound to a field type by its `id`.
6
+
7
+ /** A path through a content tree, relative to a section root, e.g. ['programs', '<itemId>', 'title']. */
8
+ export type FieldPath = string[];
9
+
10
+ /** A translatable text leaf discovered by walking a value — becomes an i18n key (ADR-0005). */
11
+ export interface TranslatableLeaf {
12
+ /** Structural path relative to the section root; joined with the section prefix to form the i18n key. */
13
+ path: FieldPath;
14
+ /** Main-locale source text (or serialized rich JSON for `format: 'rich'`). */
15
+ sourceText: string;
16
+ format: 'plain' | 'rich';
17
+ }
18
+
19
+ /** Localized-text lookup passed to `hydrate` — prefilled by the service via `localizeMany`. */
20
+ export interface HydrateCtx {
21
+ text(path: FieldPath): string;
22
+ }
23
+
24
+ /**
25
+ * A field declaration — what a section author writes (e.g. `text()`, `union('type', {...})`). Pure data:
26
+ * a field-type `id` plus type-specific `cfg`. The runtime behaviour lives in the registered {@link FieldType}
27
+ * (keyed by `id`); the React editor lives in a separate registry. `cfg` carries child declarations for the
28
+ * composite types (object/list/union), so the whole schema is a self-describing tree.
29
+ */
30
+ export interface FieldDecl<Cfg = unknown> {
31
+ type: string;
32
+ label?: string;
33
+ cfg?: Cfg;
34
+ /** Display-only show/hide based on sibling values (evaluated by the form engine for object children). */
35
+ visible?: (siblings: Record<string, unknown>) => boolean;
36
+ }
37
+
38
+ /**
39
+ * A field declaration carrying its inferred public value type as a phantom, so `InferValue` can recover the
40
+ * exact (discriminated-union-narrowable) shape the public component receives. The authoring helpers in
41
+ * ./builtins return these; `__value` is never present at runtime.
42
+ */
43
+ export interface TypedDecl<Value, Cfg = unknown> extends FieldDecl<Cfg> {
44
+ readonly __value?: Value;
45
+ }
46
+
47
+ /** How the form engine treats a field: `leaf` = one editor (no generic recursion); the rest recurse. */
48
+ export type FieldKind = 'leaf' | 'object' | 'list' | 'union';
49
+
50
+ /**
51
+ * The runtime behaviour of a field type, registered by `id`. `Storage` is the persisted/save-payload shape;
52
+ * `Value` is what the public component reads. Composite types recurse into their children via the registry.
53
+ */
54
+ export interface FieldType<Storage = unknown, Value = unknown> {
55
+ readonly id: string;
56
+ readonly kind: FieldKind;
57
+ /** A fresh, valid storage value for a new field/item. */
58
+ empty(decl: FieldDecl): Storage;
59
+ /** Validate (and coerce) raw input into Storage; throws on a genuine type mismatch. */
60
+ validate(decl: FieldDecl, raw: unknown): Storage;
61
+ /**
62
+ * Every translatable leaf under this value, by structural path. Returns leaves for the structure PRESENT
63
+ * (list items, the active union variant) regardless of whether the text is empty — so the same walk yields
64
+ * the i18n key set on both save (from the payload) and load (from the stored config).
65
+ */
66
+ leaves(decl: FieldDecl, storage: Storage, path: FieldPath): TranslatableLeaf[];
67
+ /** Produce the public Value: config passes through; translatable leaves resolve via `ctx.text(path)`. */
68
+ hydrate(decl: FieldDecl, storage: Storage, ctx: HydrateCtx, path: FieldPath): Value;
69
+ /**
70
+ * Project a save-payload Storage to the value PERSISTED in the config blob: structurally identical, but
71
+ * every translatable leaf blanked (its source lives in i18n). Keeps config free of duplicated source text
72
+ * while preserving the structure (object keys, list item ids, the active union variant) that `leaves`
73
+ * needs to reconstruct the i18n key set on load.
74
+ */
75
+ toConfig(decl: FieldDecl, storage: Storage): Storage;
76
+ /**
77
+ * The inverse of {@link toConfig} for the admin editor's load: fill translatable leaves from localized text
78
+ * (the main-locale source, for editing) while PRESERVING the storage shape — crucially list item ids — so a
79
+ * save round-trips stable ids and stable i18n keys. (hydrate drops list ids to give a clean public array;
80
+ * the editor needs them, hence this distinct projection.)
81
+ */
82
+ fill(decl: FieldDecl, config: Storage, ctx: HydrateCtx, path: FieldPath): Storage;
83
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { contentPlugin } from './index';
3
+
4
+ // Manifest smoke (roadmap M0): guards the content plugin's wiring + that every api function is gated by
5
+ // content.manage (the plugin's single permission). Field/key-stability behaviour is covered by field.test.ts.
6
+
7
+ describe('contentPlugin manifest', () => {
8
+ it('declares id, owned tables, and the content.manage permission', () => {
9
+ expect(contentPlugin.id).toBe('content');
10
+ expect(contentPlugin.tables).toEqual(['content_section_values', 'content_collection_items']);
11
+ expect(contentPlugin.dependsOn ?? []).toEqual([]);
12
+ expect((contentPlugin.permissions ?? []).map((p) => p.id)).toContain('content.manage');
13
+ });
14
+
15
+ it('gates every api function on content.manage (§10 defense-in-depth)', () => {
16
+ const api = contentPlugin.api ?? [];
17
+ expect(api.length).toBeGreaterThan(0);
18
+ for (const fn of api) {
19
+ expect(typeof fn.id).toBe('string');
20
+ expect(fn.permission, `api fn "${fn.id}" must declare a permission`).toBe('content.manage');
21
+ }
22
+ });
23
+ });
package/src/index.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { definePlugin } from '@saena-io/plugin-sdk';
3
+ import {
4
+ collectionItemRef,
5
+ collectionListInput,
6
+ collectionReorderInput,
7
+ collectionSaveInput,
8
+ contentFns,
9
+ sectionRef,
10
+ sectionSaveInput,
11
+ } from './contract';
12
+ import {
13
+ deleteItem,
14
+ getItemForEdit,
15
+ getSectionForEdit,
16
+ listItems,
17
+ reorderItems,
18
+ saveItem,
19
+ saveSection,
20
+ } from './service';
21
+
22
+ // @saena-io/content — the SERVER half of the content SDK: the `Plugin` contract (migrations, api, permissions).
23
+ // React-free by construction (ADR-0006) so it stays out of the client/SSR React graph. The field-type core
24
+ // (./field), section registry (./define), admin form engine (./admin), and public readers (./public) are the
25
+ // other entries. Sections themselves live in the client app, built on this SDK (ADR-0008).
26
+
27
+ // Public-site reads (a page's sections, or a collection's entries) for the host's SSR loaders to call directly —
28
+ // public rendering is SSR in the route loader, not a client RPC.
29
+ export { getPageContent, getSectionContent, getItemBySlug, listItems } from './service';
30
+ export type { SaveSectionInput, SaveCollectionItemInput, CollectionItemRecord } from './service';
31
+
32
+ export const contentPlugin = definePlugin({
33
+ id: 'content',
34
+ name: 'Content',
35
+ summary: 'No-code pages, sections, and routed collections for the public site.',
36
+ category: 'content',
37
+ permissions: [
38
+ {
39
+ id: 'content.manage',
40
+ description: 'Edit site content (sections + collections)',
41
+ tier: 'editor',
42
+ },
43
+ ],
44
+ // The tables this plugin owns in the shared `app` schema — declared so the host fails fast on a collision
45
+ // with core or another plugin (roadmap M0).
46
+ tables: ['content_section_values', 'content_collection_items'],
47
+ // Drizzle migrations live beside the package (../drizzle); the host applies them via runPluginMigrations
48
+ // after the core schema (ADR-0006). Resolved from import.meta.url so it works in dev.
49
+ migrations: fileURLToPath(new URL('../drizzle', import.meta.url)),
50
+ api: [
51
+ {
52
+ id: contentFns.sectionGet,
53
+ permission: 'content.manage',
54
+ handler: (input, ctx) => {
55
+ const { page, section } = sectionRef.parse(input);
56
+ return getSectionForEdit(ctx, page, section);
57
+ },
58
+ },
59
+ {
60
+ id: contentFns.sectionSave,
61
+ permission: 'content.manage',
62
+ handler: async (input, ctx) => {
63
+ await saveSection(ctx, sectionSaveInput.parse(input));
64
+ return { ok: true };
65
+ },
66
+ },
67
+ // Routed collections (ADR-0011) — the admin manager's CRUD. Reads (list/by-slug) for the public site are
68
+ // SSR server-direct exports above, not RPC.
69
+ {
70
+ id: contentFns.collectionList,
71
+ permission: 'content.manage',
72
+ handler: (input, ctx) => {
73
+ const { collection, limit, offset } = collectionListInput.parse(input);
74
+ return listItems(ctx, collection, { limit, offset });
75
+ },
76
+ },
77
+ {
78
+ id: contentFns.collectionGetForEdit,
79
+ permission: 'content.manage',
80
+ handler: (input, ctx) => {
81
+ const { collection, id } = collectionItemRef.parse(input);
82
+ return getItemForEdit(ctx, collection, id);
83
+ },
84
+ },
85
+ {
86
+ id: contentFns.collectionSave,
87
+ permission: 'content.manage',
88
+ handler: (input, ctx) => saveItem(ctx, collectionSaveInput.parse(input)),
89
+ },
90
+ {
91
+ id: contentFns.collectionDelete,
92
+ permission: 'content.manage',
93
+ handler: async (input, ctx) => {
94
+ await deleteItem(ctx, collectionItemRef.parse(input));
95
+ return { ok: true };
96
+ },
97
+ },
98
+ {
99
+ id: contentFns.collectionReorder,
100
+ permission: 'content.manage',
101
+ handler: async (input, ctx) => {
102
+ const { collection, orderedIds } = collectionReorderInput.parse(input);
103
+ await reorderItems(ctx, collection, orderedIds);
104
+ return { ok: true };
105
+ },
106
+ },
107
+ ],
108
+ });