@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,133 @@
1
+ import { type AssetRef, IMAGE_WIDTH_LADDER } from '@saena-io/plugin-sdk';
2
+ import { RichTextStatic } from '@saena-io/ui/components/rich-text/static';
3
+ import type { ComponentType } from 'react';
4
+ import type { ImageValue } from '../field';
5
+
6
+ // @saena-io/content/public — the public-site readers a section's React component uses to render field values.
7
+ // Editor-free (RichTextStatic ships no editor), so the public bundle stays editor-free (ADR-0005/0008). v1
8
+ // exposes the richtext reader; custom field types provide their own public reader alongside their editor. The
9
+ // page composition + section components live in the client app and call these.
10
+
11
+ /** Render a `richtext` field value (the stored serialized-Slate string) for the public site, editor-free. */
12
+ export function ContentRichText({ value, className }: { value: string; className?: string }) {
13
+ return <RichTextStatic value={value} className={className} />;
14
+ }
15
+
16
+ // webp is requested for all variants (universal support, small). Widths come from the shared IMAGE_WIDTH_LADDER
17
+ // (the serve route snaps to the same ladder, so descriptors are honest and the derivative cache stays bounded).
18
+ const CENTRE = { x: 0.5, y: 0.5 };
19
+
20
+ /** Build a derivative URL for the serve route (ADR-0009): `/api/assets/<id>?w=&fmt=webp[&ar=&fx=&fy=&q=]`. */
21
+ function variantUrl(
22
+ url: string,
23
+ width: number,
24
+ ratio: number | undefined,
25
+ hot: { x: number; y: number },
26
+ quality: number | undefined,
27
+ ): string {
28
+ const p = new URLSearchParams({ w: String(width), fmt: 'webp' });
29
+ if (ratio) {
30
+ p.set('ar', String(ratio));
31
+ p.set('fx', String(hot.x));
32
+ p.set('fy', String(hot.y));
33
+ }
34
+ if (quality) p.set('q', String(quality));
35
+ return `${url}?${p.toString()}`;
36
+ }
37
+
38
+ /**
39
+ * Render an `image` field value for the public site (ADR-0009): an optimized, responsive `<img>` — a webp
40
+ * `srcset` of widths the browser picks from by DPR + the `sizes` hint, with intrinsic `width`/`height` set so
41
+ * there's no layout shift. Pass `ratio` (w/h) to render at a fixed aspect: variants are cropped to it, centred
42
+ * on the field's focal point (`hotspot`), so the subject stays in frame across breakpoints. Editor-free (just an
43
+ * `<img>`), so the public bundle stays editor-free. While the image loads, its dominant colour (when known) is
44
+ * shown as the background so the reserved box isn't blank. Renders nothing until an image is set.
45
+ */
46
+ export function ContentImage({
47
+ value,
48
+ ratio,
49
+ sizes = '100vw',
50
+ quality,
51
+ priority = false,
52
+ className,
53
+ }: {
54
+ value: ImageValue;
55
+ /** Render aspect ratio (w/h). When set, variants are cropped to it, centred on the focal point. */
56
+ ratio?: number;
57
+ /** The `sizes` attribute — how wide the image renders per breakpoint (e.g. `(min-width:768px) 50vw, 100vw`). */
58
+ sizes?: string;
59
+ /** Override the webp encoder quality (1–100) for this usage; omitted uses the pipeline's tuned default. */
60
+ quality?: number;
61
+ /** Eager-load above-the-fold images (default lazy). */
62
+ priority?: boolean;
63
+ className?: string;
64
+ }) {
65
+ const ref: AssetRef | null = value.ref;
66
+ if (!ref) return null;
67
+ const loading = priority ? 'eager' : 'lazy';
68
+ // Dominant colour as a load placeholder (when probed); the opaque image paints over it.
69
+ const placeholder = ref.dominantColor ? { backgroundColor: ref.dominantColor } : undefined;
70
+
71
+ // Without intrinsic dimensions (a pre-ADR-0009 upload), or a source narrower than the smallest ladder width
72
+ // (a srcset would add nothing), serve the original as a plain, still-lazy `<img>`.
73
+ const maxW =
74
+ ratio && ref.width && ref.height && ratio < ref.width / ref.height
75
+ ? Math.round(ref.height * ratio)
76
+ : (ref.width ?? 0);
77
+ const ladder = IMAGE_WIDTH_LADDER.filter((w) => w <= maxW);
78
+ if (!ref.width || !ref.height || ladder.length === 0) {
79
+ return (
80
+ <img
81
+ src={ref.url}
82
+ alt={value.alt}
83
+ width={ref.width}
84
+ height={ref.height}
85
+ loading={loading}
86
+ decoding="async"
87
+ style={placeholder}
88
+ className={className}
89
+ />
90
+ );
91
+ }
92
+
93
+ const hot = value.hotspot ?? CENTRE;
94
+ // Largest deliverable width = top of the per-image ladder (non-empty here); intrinsic width/height carry the
95
+ // render aspect (ratio when cropping, else the source's), so the box is reserved with no layout shift.
96
+ const outW = ladder[ladder.length - 1] ?? maxW;
97
+ const outH = ratio ? Math.round(outW / ratio) : Math.round(outW / (ref.width / ref.height));
98
+
99
+ return (
100
+ <img
101
+ src={variantUrl(ref.url, outW, ratio, hot, quality)}
102
+ srcSet={ladder.map((w) => `${variantUrl(ref.url, w, ratio, hot, quality)} ${w}w`).join(', ')}
103
+ sizes={sizes}
104
+ alt={value.alt}
105
+ width={outW}
106
+ height={outH}
107
+ style={placeholder}
108
+ loading={loading}
109
+ decoding="async"
110
+ className={className}
111
+ />
112
+ );
113
+ }
114
+
115
+ // --- Section component registry --------------------------------------------------------------------------
116
+ // A section's public component, keyed by its section id. Registering one lets the admin's live preview render
117
+ // the real public component from the in-editor value (the same component the public route renders), so the
118
+ // preview is WYSIWYG. Section composition stays code (definePage); this only makes the components addressable.
119
+
120
+ /** A section's public component — receives its hydrated `content`. */
121
+ export type SectionComponent<C = unknown> = ComponentType<{ content: C }>;
122
+
123
+ const sectionComponents = new Map<string, SectionComponent>();
124
+
125
+ /** Register a section's public component by id (call once at module load, alongside the component). */
126
+ export function registerSectionComponent<C>(id: string, component: SectionComponent<C>): void {
127
+ sectionComponents.set(id, component as SectionComponent);
128
+ }
129
+
130
+ /** The registered public component for a section id, if any (the admin live preview reads this). */
131
+ export function getSectionComponent(id: string): SectionComponent | undefined {
132
+ return sectionComponents.get(id);
133
+ }
package/src/service.ts ADDED
@@ -0,0 +1,375 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { RequestContext, TranslatableInput } from '@saena-io/plugin-sdk';
3
+ import { and, asc, eq, sql } from 'drizzle-orm';
4
+ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
5
+ import { contentCollectionItems, contentSectionValues } from './db/schema';
6
+ import { type CollectionDef, getCollection, getPage, getSection } from './define';
7
+ import {
8
+ fieldEmpty,
9
+ fieldToConfig,
10
+ fieldValidate,
11
+ fillSection,
12
+ hydrateSection,
13
+ sectionLeaves,
14
+ slugify,
15
+ } from './field';
16
+
17
+ // The content service (ADR-0008). Sections + pages are CODE (the registry in ./define); this persists only
18
+ // the editable VALUES: structure/config in content_section_values, and every translatable leaf as an i18n key
19
+ // via the field core's leaf walk. Live-edit (no draft/publish in v1). Plugins query their own tables through
20
+ // ctx.db, which the SDK keeps opaque (Database = unknown) — cast to the drizzle handle the core provides.
21
+ function db(ctx: RequestContext): PostgresJsDatabase {
22
+ return ctx.db as PostgresJsDatabase;
23
+ }
24
+
25
+ /** The i18n key prefix for a section instance: `content.<page>.<section>`. */
26
+ function sectionPrefix(page: string, section: string): string {
27
+ return `content.${page}.${section}`;
28
+ }
29
+
30
+ export interface SaveSectionInput {
31
+ page: string;
32
+ section: string;
33
+ /** The section's value as produced by the admin editor (validated against the section schema here). */
34
+ value: unknown;
35
+ }
36
+
37
+ /**
38
+ * Persist a section's content: validate against its schema, prune the i18n keys of any removed list items /
39
+ * abandoned union variants, store the config projection (text blanked), then sync every text leaf to i18n.
40
+ */
41
+ export async function saveSection(ctx: RequestContext, input: SaveSectionInput): Promise<void> {
42
+ ctx.requirePermission('content.manage');
43
+ const def = getSection(input.section);
44
+ if (!def) throw new Error(`unknown content section: ${input.section}`);
45
+ const prefix = sectionPrefix(input.page, input.section);
46
+ const storage = fieldValidate(def.schema, input.value);
47
+
48
+ const [existing] = await db(ctx)
49
+ .select({ config: contentSectionValues.config })
50
+ .from(contentSectionValues)
51
+ .where(
52
+ and(
53
+ eq(contentSectionValues.page, input.page),
54
+ eq(contentSectionValues.section, input.section),
55
+ ),
56
+ )
57
+ .limit(1);
58
+
59
+ const leaves = sectionLeaves(def.schema, storage, prefix);
60
+ const newKeys = new Set(leaves.map((l) => l.key));
61
+ const config = fieldToConfig(def.schema, storage);
62
+ const fields: TranslatableInput[] = leaves.map((l) => ({
63
+ key: l.key,
64
+ sourceText: l.sourceText,
65
+ format: l.format,
66
+ }));
67
+ // One transaction: prune removed i18n keys, write the config projection, and sync the text leaves together —
68
+ // so a mid-save failure can never leave the config and its translations out of sync. The i18n seam runs on
69
+ // the same `tx` (ADR-0005/0008).
70
+ await db(ctx).transaction(async (tx) => {
71
+ if (existing) {
72
+ // Keys that existed but no longer do (a removed item, a switched union variant) → prune from i18n.
73
+ const oldKeys = sectionLeaves(def.schema, existing.config, prefix).map((l) => l.key);
74
+ for (const key of oldKeys) {
75
+ if (!newKeys.has(key)) await ctx.i18n.deleteKeysByPrefix(key, { tx });
76
+ }
77
+ }
78
+ await tx
79
+ .insert(contentSectionValues)
80
+ .values({ page: input.page, section: input.section, config })
81
+ .onConflictDoUpdate({
82
+ target: [contentSectionValues.page, contentSectionValues.section],
83
+ set: { config, updatedAt: new Date() },
84
+ });
85
+ await ctx.i18n.syncTranslatable(fields, { tx });
86
+ });
87
+ }
88
+
89
+ /** `localizeMany` echoes unknown keys back as their own value (a public "missing string" marker). A content
90
+ * field with no translation yet — e.g. one newly added to an already-saved section — should read as empty in
91
+ * both the editor and the public render, so drop the echoes (value === key) before hydrate/fill. */
92
+ function dropMissing(localized: Record<string, string>): Record<string, string> {
93
+ const out: Record<string, string> = {};
94
+ for (const [k, v] of Object.entries(localized)) if (v !== k) out[k] = v;
95
+ return out;
96
+ }
97
+
98
+ /** Load a section's content, hydrated + localized for `ctx.locale`. Unsaved sections hydrate to empty values. */
99
+ export async function getSectionContent(
100
+ ctx: RequestContext,
101
+ page: string,
102
+ section: string,
103
+ ): Promise<unknown> {
104
+ const def = getSection(section);
105
+ if (!def) throw new Error(`unknown content section: ${section}`);
106
+ const prefix = sectionPrefix(page, section);
107
+ const [row] = await db(ctx)
108
+ .select({ config: contentSectionValues.config })
109
+ .from(contentSectionValues)
110
+ .where(and(eq(contentSectionValues.page, page), eq(contentSectionValues.section, section)))
111
+ .limit(1);
112
+
113
+ if (!row) {
114
+ // Never saved: hydrate the empty config with no localized text (text leaves → '').
115
+ const empty = fieldToConfig(def.schema, fieldEmpty(def.schema));
116
+ return hydrateSection(def.schema, empty, {}, prefix);
117
+ }
118
+ const keys = sectionLeaves(def.schema, row.config, prefix).map((l) => l.key);
119
+ const localized = dropMissing(await ctx.i18n.localizeMany(keys, ctx.locale));
120
+ return hydrateSection(def.schema, row.config, localized, prefix);
121
+ }
122
+
123
+ /**
124
+ * Load a section's content for the ADMIN editor: the storage shape (list item ids preserved) with translatable
125
+ * leaves filled from the editing locale's source — so the editor mutates a complete payload that saves back
126
+ * with stable ids + i18n keys. Distinct from getSectionContent, which gives the public a clean value shape.
127
+ */
128
+ export async function getSectionForEdit(
129
+ ctx: RequestContext,
130
+ page: string,
131
+ section: string,
132
+ ): Promise<unknown> {
133
+ const def = getSection(section);
134
+ if (!def) throw new Error(`unknown content section: ${section}`);
135
+ const prefix = sectionPrefix(page, section);
136
+ const [row] = await db(ctx)
137
+ .select({ config: contentSectionValues.config })
138
+ .from(contentSectionValues)
139
+ .where(and(eq(contentSectionValues.page, page), eq(contentSectionValues.section, section)))
140
+ .limit(1);
141
+
142
+ const config = row?.config ?? fieldToConfig(def.schema, fieldEmpty(def.schema));
143
+ if (!row) return fillSection(def.schema, config, {}, prefix);
144
+ const keys = sectionLeaves(def.schema, config, prefix).map((l) => l.key);
145
+ const localized = dropMissing(await ctx.i18n.localizeMany(keys, ctx.locale));
146
+ return fillSection(def.schema, config, localized, prefix);
147
+ }
148
+
149
+ /** Load every section of a page, keyed by section id — the public route's hydration payload. */
150
+ export async function getPageContent(
151
+ ctx: RequestContext,
152
+ page: string,
153
+ ): Promise<Record<string, unknown> | null> {
154
+ const def = getPage(page);
155
+ if (!def) return null;
156
+ const out: Record<string, unknown> = {};
157
+ for (const section of def.sections) out[section] = await getSectionContent(ctx, page, section);
158
+ return out;
159
+ }
160
+
161
+ // --- Routed collections (ADR-0011) -----------------------------------------------------------------------
162
+ // One row per entry in content_collection_items; the per-entry field pipeline is the SAME field core sections
163
+ // use. Each entry's translatable leaves are rooted at its STABLE row id — `content.collection.<c>.<id>.…` — so
164
+ // reorder/rename never re-keys or orphans translations. Saves are transactional (config + i18n together).
165
+
166
+ /** The i18n key prefix for a collection entry — rooted at the entry's stable row id (ADR-0011). */
167
+ function collectionItemPrefix(collection: string, id: string): string {
168
+ return `content.collection.${collection}.${id}`;
169
+ }
170
+
171
+ function requireCollection(id: string): CollectionDef {
172
+ const def = getCollection(id);
173
+ if (!def) throw new Error(`unknown content collection: ${id}`);
174
+ return def;
175
+ }
176
+
177
+ /** A collection entry as the service returns it: identity + slug + the (opaque) hydrated value. Callers cast
178
+ * `value` to their schema's `InferValue` via `CollectionItem<typeof def>`. */
179
+ export interface CollectionItemRecord {
180
+ id: string;
181
+ slug: string;
182
+ value: unknown;
183
+ }
184
+
185
+ export interface SaveCollectionItemInput {
186
+ collection: string;
187
+ /** Omit to create a new entry; provide to update an existing one. */
188
+ id?: string;
189
+ slug: string;
190
+ /** The entry value from the admin editor (validated server-side against the collection schema). */
191
+ value: unknown;
192
+ }
193
+
194
+ /** Create or update a collection entry. In ONE transaction: upsert the row, prune removed i18n keys, and sync
195
+ * the text leaves (rooted at the entry's stable id). Returns the entry id. */
196
+ export async function saveItem(
197
+ ctx: RequestContext,
198
+ input: SaveCollectionItemInput,
199
+ ): Promise<{ id: string }> {
200
+ ctx.requirePermission('content.manage');
201
+ const def = requireCollection(input.collection);
202
+ const id = input.id ?? randomUUID();
203
+ const slug = slugify(input.slug) || id;
204
+ const prefix = collectionItemPrefix(input.collection, id);
205
+ const storage = fieldValidate(def.schema, input.value);
206
+ const leaves = sectionLeaves(def.schema, storage, prefix);
207
+ const newKeys = new Set(leaves.map((l) => l.key));
208
+ const config = fieldToConfig(def.schema, storage);
209
+ const fields: TranslatableInput[] = leaves.map((l) => ({
210
+ key: l.key,
211
+ sourceText: l.sourceText,
212
+ format: l.format,
213
+ }));
214
+
215
+ await db(ctx).transaction(async (tx) => {
216
+ const [existing] = await tx
217
+ .select({ config: contentCollectionItems.config })
218
+ .from(contentCollectionItems)
219
+ .where(eq(contentCollectionItems.id, id))
220
+ .limit(1);
221
+ if (existing) {
222
+ // Prune keys that existed but no longer do (a removed list item / switched union variant).
223
+ const oldKeys = sectionLeaves(def.schema, existing.config, prefix).map((l) => l.key);
224
+ for (const key of oldKeys) {
225
+ if (!newKeys.has(key)) await ctx.i18n.deleteKeysByPrefix(key, { tx });
226
+ }
227
+ }
228
+ // New entries append at the end of the collection's order.
229
+ let sortOrder = 0;
230
+ if (!existing) {
231
+ const [m] = await tx
232
+ .select({ max: sql<number>`coalesce(max(${contentCollectionItems.sortOrder}), -1)` })
233
+ .from(contentCollectionItems)
234
+ .where(eq(contentCollectionItems.collection, input.collection));
235
+ sortOrder = (m?.max ?? -1) + 1;
236
+ }
237
+ await tx
238
+ .insert(contentCollectionItems)
239
+ .values({ id, collection: input.collection, slug, config, sortOrder })
240
+ .onConflictDoUpdate({
241
+ target: contentCollectionItems.id,
242
+ set: { slug, config, updatedAt: new Date() },
243
+ });
244
+ await ctx.i18n.syncTranslatable(fields, { tx });
245
+ });
246
+ return { id };
247
+ }
248
+
249
+ /** Delete a collection entry + its i18n keys, atomically. */
250
+ export async function deleteItem(
251
+ ctx: RequestContext,
252
+ input: { collection: string; id: string },
253
+ ): Promise<void> {
254
+ ctx.requirePermission('content.manage');
255
+ const prefix = collectionItemPrefix(input.collection, input.id);
256
+ await db(ctx).transaction(async (tx) => {
257
+ await tx
258
+ .delete(contentCollectionItems)
259
+ .where(
260
+ and(
261
+ eq(contentCollectionItems.collection, input.collection),
262
+ eq(contentCollectionItems.id, input.id),
263
+ ),
264
+ );
265
+ await ctx.i18n.deleteKeysByPrefix(prefix, { tx });
266
+ });
267
+ }
268
+
269
+ /** Set the collection's order from an explicit id sequence (the admin manager's drag-to-reorder). */
270
+ export async function reorderItems(
271
+ ctx: RequestContext,
272
+ collection: string,
273
+ orderedIds: string[],
274
+ ): Promise<void> {
275
+ ctx.requirePermission('content.manage');
276
+ await db(ctx).transaction(async (tx) => {
277
+ for (const [i, id] of orderedIds.entries()) {
278
+ await tx
279
+ .update(contentCollectionItems)
280
+ .set({ sortOrder: i, updatedAt: new Date() })
281
+ .where(
282
+ and(eq(contentCollectionItems.collection, collection), eq(contentCollectionItems.id, id)),
283
+ );
284
+ }
285
+ });
286
+ }
287
+
288
+ /** Hydrate rows into public records, batching the i18n lookup across all rows (no N+1). */
289
+ async function hydrateRows(
290
+ ctx: RequestContext,
291
+ def: CollectionDef,
292
+ collection: string,
293
+ rows: { id: string; slug: string; config: unknown }[],
294
+ ): Promise<CollectionItemRecord[]> {
295
+ const withPrefix = rows.map((row) => ({ row, prefix: collectionItemPrefix(collection, row.id) }));
296
+ const allKeys = withPrefix.flatMap(({ row, prefix }) =>
297
+ sectionLeaves(def.schema, row.config, prefix).map((l) => l.key),
298
+ );
299
+ const localized = allKeys.length
300
+ ? dropMissing(await ctx.i18n.localizeMany(allKeys, ctx.locale))
301
+ : {};
302
+ return withPrefix.map(({ row, prefix }) => ({
303
+ id: row.id,
304
+ slug: row.slug,
305
+ value: hydrateSection(def.schema, row.config, localized, prefix),
306
+ }));
307
+ }
308
+
309
+ export interface ListItemsOptions {
310
+ limit?: number;
311
+ offset?: number;
312
+ }
313
+
314
+ /** List a collection's entries (ordered, hydrated + localized) — the list/archive route's payload. */
315
+ export async function listItems(
316
+ ctx: RequestContext,
317
+ collection: string,
318
+ opts?: ListItemsOptions,
319
+ ): Promise<CollectionItemRecord[]> {
320
+ const def = requireCollection(collection);
321
+ let q = db(ctx)
322
+ .select()
323
+ .from(contentCollectionItems)
324
+ .where(eq(contentCollectionItems.collection, collection))
325
+ .orderBy(asc(contentCollectionItems.sortOrder))
326
+ .$dynamic();
327
+ if (opts?.limit != null) q = q.limit(opts.limit);
328
+ if (opts?.offset != null) q = q.offset(opts.offset);
329
+ return hydrateRows(ctx, def, collection, await q);
330
+ }
331
+
332
+ /** Load one entry by its `$slug` route segment (hydrated + localized), or null — the detail route's payload. */
333
+ export async function getItemBySlug(
334
+ ctx: RequestContext,
335
+ collection: string,
336
+ slug: string,
337
+ ): Promise<CollectionItemRecord | null> {
338
+ const def = requireCollection(collection);
339
+ const [row] = await db(ctx)
340
+ .select()
341
+ .from(contentCollectionItems)
342
+ .where(
343
+ and(eq(contentCollectionItems.collection, collection), eq(contentCollectionItems.slug, slug)),
344
+ )
345
+ .limit(1);
346
+ if (!row) return null;
347
+ const [item] = await hydrateRows(ctx, def, collection, [row]);
348
+ return item ?? null;
349
+ }
350
+
351
+ /** Load one entry by id for the ADMIN editor: the storage shape (list item ids preserved) with text filled
352
+ * from the editing locale — symmetric with getSectionForEdit. */
353
+ export async function getItemForEdit(
354
+ ctx: RequestContext,
355
+ collection: string,
356
+ id: string,
357
+ ): Promise<CollectionItemRecord | null> {
358
+ const def = requireCollection(collection);
359
+ const [row] = await db(ctx)
360
+ .select()
361
+ .from(contentCollectionItems)
362
+ .where(
363
+ and(eq(contentCollectionItems.collection, collection), eq(contentCollectionItems.id, id)),
364
+ )
365
+ .limit(1);
366
+ if (!row) return null;
367
+ const prefix = collectionItemPrefix(collection, id);
368
+ const keys = sectionLeaves(def.schema, row.config, prefix).map((l) => l.key);
369
+ const localized = dropMissing(await ctx.i18n.localizeMany(keys, ctx.locale));
370
+ return {
371
+ id: row.id,
372
+ slug: row.slug,
373
+ value: fillSection(def.schema, row.config, localized, prefix),
374
+ };
375
+ }