@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.
- package/drizzle/0000_tranquil_golden_guardian.sql +16 -0
- package/drizzle/0001_little_darwin.sql +12 -0
- package/drizzle/meta/0000_snapshot.json +94 -0
- package/drizzle/meta/0001_snapshot.json +197 -0
- package/drizzle/meta/_journal.json +20 -0
- package/package.json +41 -0
- package/src/admin/client.ts +43 -0
- package/src/admin/collection-manager.tsx +328 -0
- package/src/admin/editors.tsx +284 -0
- package/src/admin/field-registry.tsx +33 -0
- package/src/admin/form-engine.tsx +394 -0
- package/src/admin/icons.tsx +45 -0
- package/src/admin/index.tsx +343 -0
- package/src/contract.ts +57 -0
- package/src/db/schema.ts +70 -0
- package/src/define.ts +92 -0
- package/src/field/builtins.ts +524 -0
- package/src/field/field.test.ts +292 -0
- package/src/field/index.ts +49 -0
- package/src/field/infer.ts +10 -0
- package/src/field/keys.ts +69 -0
- package/src/field/registry.ts +32 -0
- package/src/field/types.ts +83 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +108 -0
- package/src/public/index.tsx +133 -0
- package/src/service.ts +375 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { type AdminExtension, type AdminNavItem, type AdminRoute, Page } from '@saena-io/ui/admin';
|
|
2
|
+
import { Button } from '@saena-io/ui/components/button';
|
|
3
|
+
import {
|
|
4
|
+
Collapsible,
|
|
5
|
+
CollapsibleContent,
|
|
6
|
+
CollapsibleTrigger,
|
|
7
|
+
} from '@saena-io/ui/components/collapsible';
|
|
8
|
+
import { RichTextDndProvider } from '@saena-io/ui/components/rich-text/rich-text-editor';
|
|
9
|
+
import { Component, type ReactNode, memo, useEffect, useState, useSyncExternalStore } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
type PageDef,
|
|
12
|
+
type SectionDef,
|
|
13
|
+
allCollections,
|
|
14
|
+
allPages,
|
|
15
|
+
getPage,
|
|
16
|
+
getSection,
|
|
17
|
+
} from '../define';
|
|
18
|
+
import { hydratePreview } from '../field';
|
|
19
|
+
import { getSectionComponent } from '../public';
|
|
20
|
+
import { contentClient } from './client';
|
|
21
|
+
import { CollectionManager } from './collection-manager';
|
|
22
|
+
import './editors'; // registers the built-in field-type editors
|
|
23
|
+
import { FieldView } from './form-engine';
|
|
24
|
+
import { Chevron } from './icons';
|
|
25
|
+
|
|
26
|
+
// @saena-io/content/admin — the CLIENT half (ADR-0008). One admin screen PER public page, full-bleed: the page's
|
|
27
|
+
// sections stack as edge-to-edge panels separated by borders (no cards — avoids the nested-card look), beside a
|
|
28
|
+
// preview pane split off by a vertical border. Page navigation lives in the main admin sidebar as a collapsible
|
|
29
|
+
// "Content" group listing every page (built by buildContentAdmin from the registry — call it AFTER the
|
|
30
|
+
// project's sections/pages register).
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One section, edited in a full-width, collapsible panel (no card): a bg-primary header that toggles the panel,
|
|
34
|
+
* the generated form body, and a footer with Reset + Save. `loaded` is the last server snapshot — Reset reverts
|
|
35
|
+
* to it (remounting the form via `formKey` so uncontrolled editors, e.g. rich text, re-initialise).
|
|
36
|
+
*/
|
|
37
|
+
function SectionPanel({
|
|
38
|
+
page,
|
|
39
|
+
section,
|
|
40
|
+
store,
|
|
41
|
+
}: {
|
|
42
|
+
page: string;
|
|
43
|
+
section: string;
|
|
44
|
+
/** The live-preview store this section publishes its current value into (on load, every edit, and reset). */
|
|
45
|
+
store: PreviewStore;
|
|
46
|
+
}) {
|
|
47
|
+
const def = getSection(section);
|
|
48
|
+
const [value, setValue] = useState<unknown>(undefined);
|
|
49
|
+
const [loaded, setLoaded] = useState<unknown>(undefined);
|
|
50
|
+
const [formKey, setFormKey] = useState(0);
|
|
51
|
+
const [saving, setSaving] = useState(false);
|
|
52
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
let live = true;
|
|
56
|
+
setValue(undefined);
|
|
57
|
+
setStatus(null);
|
|
58
|
+
contentClient
|
|
59
|
+
.getSection(page, section)
|
|
60
|
+
.then((v) => {
|
|
61
|
+
if (!live) return;
|
|
62
|
+
setValue(v);
|
|
63
|
+
setLoaded(v);
|
|
64
|
+
store.set(section, v);
|
|
65
|
+
})
|
|
66
|
+
.catch((e) => live && setStatus(String(e)));
|
|
67
|
+
return () => {
|
|
68
|
+
live = false;
|
|
69
|
+
};
|
|
70
|
+
}, [page, section, store]);
|
|
71
|
+
|
|
72
|
+
if (!def) return null;
|
|
73
|
+
|
|
74
|
+
// Every edit updates local state AND the preview store.
|
|
75
|
+
const edit = (v: unknown) => {
|
|
76
|
+
setValue(v);
|
|
77
|
+
store.set(section, v);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Edits always create new object trees (immutable updates in the form engine), so a reference compare
|
|
81
|
+
// against the last-saved/loaded snapshot is an accurate dirty check.
|
|
82
|
+
const dirty = value !== loaded;
|
|
83
|
+
|
|
84
|
+
const save = async () => {
|
|
85
|
+
setSaving(true);
|
|
86
|
+
setStatus(null);
|
|
87
|
+
try {
|
|
88
|
+
await contentClient.saveSection({ page, section, value });
|
|
89
|
+
setLoaded(value);
|
|
90
|
+
setStatus('Saved');
|
|
91
|
+
} catch (e) {
|
|
92
|
+
setStatus(String(e));
|
|
93
|
+
} finally {
|
|
94
|
+
setSaving(false);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const reset = () => {
|
|
99
|
+
setValue(loaded);
|
|
100
|
+
store.set(section, loaded);
|
|
101
|
+
setFormKey((k) => k + 1); // remount the form so uncontrolled editors re-init from `loaded`
|
|
102
|
+
setStatus(null);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Collapsible defaultOpen render={<section className="border-b" />}>
|
|
107
|
+
<CollapsibleTrigger className="group bg-linear-to-l from-input/30 to-input/0 flex w-full items-center justify-between gap-3 border-l-4 border-primary px-6 py-3 text-left text-secondary-foreground transition-colors hover:bg-input/30">
|
|
108
|
+
<span className="font-medium text-sm -ml-1">{def.label}</span>
|
|
109
|
+
<Chevron />
|
|
110
|
+
</CollapsibleTrigger>
|
|
111
|
+
<CollapsibleContent>
|
|
112
|
+
<div className="px-6 py-4">
|
|
113
|
+
{value === undefined ? (
|
|
114
|
+
<p className="text-muted-foreground text-sm">Loading…</p>
|
|
115
|
+
) : (
|
|
116
|
+
<FieldView
|
|
117
|
+
key={formKey}
|
|
118
|
+
field={def.schema}
|
|
119
|
+
value={value}
|
|
120
|
+
onChange={edit}
|
|
121
|
+
path={[]}
|
|
122
|
+
locale="source"
|
|
123
|
+
/>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
<footer className="flex items-center justify-end gap-2 px-6 py-3 pb-6">
|
|
127
|
+
{status ? <span className="mr-auto text-muted-foreground text-xs">{status}</span> : null}
|
|
128
|
+
<Button variant="outline" size="lg" onClick={reset} disabled={saving || !dirty}>
|
|
129
|
+
Reset
|
|
130
|
+
</Button>
|
|
131
|
+
<Button size="lg" onClick={save} disabled={saving || !dirty}>
|
|
132
|
+
{saving ? 'Saving…' : 'Save'}
|
|
133
|
+
</Button>
|
|
134
|
+
</footer>
|
|
135
|
+
</CollapsibleContent>
|
|
136
|
+
</Collapsible>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Live preview ---------------------------------------------------------------------------------------
|
|
141
|
+
// Each SectionPanel pushes its current editor value into a tiny external store; the preview subscribes and
|
|
142
|
+
// re-renders on change — so a keystroke re-renders ONLY the preview, not the (heavy) editor panels.
|
|
143
|
+
|
|
144
|
+
interface PreviewStore {
|
|
145
|
+
set(id: string, value: unknown): void;
|
|
146
|
+
get(id: string): unknown;
|
|
147
|
+
subscribe(listener: () => void): () => void;
|
|
148
|
+
getVersion(): number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createPreviewStore(): PreviewStore {
|
|
152
|
+
const values = new Map<string, unknown>();
|
|
153
|
+
const listeners = new Set<() => void>();
|
|
154
|
+
let version = 0;
|
|
155
|
+
return {
|
|
156
|
+
set(id, value) {
|
|
157
|
+
values.set(id, value);
|
|
158
|
+
version += 1;
|
|
159
|
+
for (const l of listeners) l();
|
|
160
|
+
},
|
|
161
|
+
get: (id) => values.get(id),
|
|
162
|
+
subscribe(listener) {
|
|
163
|
+
listeners.add(listener);
|
|
164
|
+
return () => {
|
|
165
|
+
listeners.delete(listener);
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
getVersion: () => version,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Isolates a section's preview render: a thrown error (from rendering with mid-edit / partial data) shows a
|
|
173
|
+
* fallback instead of crashing the admin, and the boundary self-heals when the value changes (`resetKey`). */
|
|
174
|
+
class PreviewBoundary extends Component<
|
|
175
|
+
{ resetKey: unknown; fallback: ReactNode; children: ReactNode },
|
|
176
|
+
{ failed: boolean }
|
|
177
|
+
> {
|
|
178
|
+
override state = { failed: false };
|
|
179
|
+
static getDerivedStateFromError() {
|
|
180
|
+
return { failed: true };
|
|
181
|
+
}
|
|
182
|
+
override componentDidUpdate(prev: { resetKey: unknown }) {
|
|
183
|
+
if (this.state.failed && prev.resetKey !== this.props.resetKey)
|
|
184
|
+
this.setState({ failed: false });
|
|
185
|
+
}
|
|
186
|
+
override render() {
|
|
187
|
+
return this.state.failed ? this.props.fallback : this.props.children;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Renders one section's public component from the in-editor value (client-side hydrate, no server round-trip).
|
|
192
|
+
* Memoised so an edit to one section doesn't re-render the others' previews. */
|
|
193
|
+
const SectionPreview = memo(function SectionPreview({
|
|
194
|
+
def,
|
|
195
|
+
value,
|
|
196
|
+
}: {
|
|
197
|
+
def: SectionDef;
|
|
198
|
+
value: unknown;
|
|
199
|
+
}) {
|
|
200
|
+
const Section = getSectionComponent(def.id);
|
|
201
|
+
if (!Section) {
|
|
202
|
+
return (
|
|
203
|
+
<p className="px-6 py-4 text-muted-foreground text-xs">
|
|
204
|
+
No preview component registered for “{def.label}”.
|
|
205
|
+
</p>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return <Section content={hydratePreview(def.schema, value)} />;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/** The right-hand pane: the page's sections rendered with their live editor values, as the public site will
|
|
212
|
+
* show them. Non-interactive (pointer-events-none) so it can't navigate away from the editor. */
|
|
213
|
+
function PreviewPane({ page, store }: { page: PageDef; store: PreviewStore }) {
|
|
214
|
+
useSyncExternalStore(store.subscribe, store.getVersion, store.getVersion);
|
|
215
|
+
const ready = page.sections.some((sid) => store.get(sid) !== undefined);
|
|
216
|
+
return (
|
|
217
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background">
|
|
218
|
+
<div className="border-border border-b px-4 py-2 font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
|
219
|
+
Preview
|
|
220
|
+
</div>
|
|
221
|
+
{ready ? (
|
|
222
|
+
<div className="select-none pointer-events-none">
|
|
223
|
+
{page.sections.map((sid) => {
|
|
224
|
+
const def = getSection(sid);
|
|
225
|
+
const value = store.get(sid);
|
|
226
|
+
if (!def || value === undefined) return null;
|
|
227
|
+
return (
|
|
228
|
+
<PreviewBoundary
|
|
229
|
+
key={sid}
|
|
230
|
+
resetKey={value}
|
|
231
|
+
fallback={
|
|
232
|
+
<p className="px-6 py-4 text-destructive text-xs">
|
|
233
|
+
Preview unavailable — finish editing this section.
|
|
234
|
+
</p>
|
|
235
|
+
}
|
|
236
|
+
>
|
|
237
|
+
<SectionPreview def={def} value={value} />
|
|
238
|
+
</PreviewBoundary>
|
|
239
|
+
);
|
|
240
|
+
})}
|
|
241
|
+
</div>
|
|
242
|
+
) : (
|
|
243
|
+
<p className="p-6 text-muted-foreground text-sm">Loading preview…</p>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A page's admin screen. The title goes to the top bar via <Page>; the body is full-bleed and splits in two —
|
|
251
|
+
* the section panels (the editor) on the left, the live preview on the right. One DnD provider wraps the
|
|
252
|
+
* editor column; one preview store bridges the section values to the preview.
|
|
253
|
+
*/
|
|
254
|
+
function PageScreen({ pageId }: { pageId: string }) {
|
|
255
|
+
const page = getPage(pageId);
|
|
256
|
+
const [store] = useState(createPreviewStore);
|
|
257
|
+
if (!page) {
|
|
258
|
+
return (
|
|
259
|
+
<Page title="Content" className="p-6">
|
|
260
|
+
<p className="text-destructive text-sm">Unknown page: {pageId}</p>
|
|
261
|
+
</Page>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return (
|
|
265
|
+
<Page title={page.label ?? page.id} className="min-h-0">
|
|
266
|
+
{/* Full-bleed split filling the bounded content slot (the shell fixes the page height): the editor
|
|
267
|
+
column scrolls internally, the preview conforms to the height — the page itself never scrolls. */}
|
|
268
|
+
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
|
269
|
+
<div className="flex min-h-0 flex-col overflow-y-auto md:flex-1 md:border-border md:border-r">
|
|
270
|
+
<RichTextDndProvider>
|
|
271
|
+
{page.sections.map((sid) => (
|
|
272
|
+
<SectionPanel key={sid} page={page.id} section={sid} store={store} />
|
|
273
|
+
))}
|
|
274
|
+
</RichTextDndProvider>
|
|
275
|
+
</div>
|
|
276
|
+
<aside className="hidden min-h-0 md:flex md:flex-1 md:flex-col">
|
|
277
|
+
<PreviewPane page={page} store={store} />
|
|
278
|
+
</aside>
|
|
279
|
+
</div>
|
|
280
|
+
</Page>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Build the content admin extension from the registered pages: a collapsible "Content" nav group whose
|
|
286
|
+
* sub-items are the pages, and one route per page (`content/<id>`). Call this AFTER the project's sections +
|
|
287
|
+
* pages are registered (e.g. in the admin manifest, after importing the section barrel).
|
|
288
|
+
*/
|
|
289
|
+
export function buildContentAdmin(): AdminExtension {
|
|
290
|
+
const pages = allPages();
|
|
291
|
+
const collections = allCollections();
|
|
292
|
+
const firstPage = pages[0];
|
|
293
|
+
const firstCollection = collections[0];
|
|
294
|
+
|
|
295
|
+
const nav: AdminNavItem[] = [];
|
|
296
|
+
if (firstPage) {
|
|
297
|
+
nav.push({
|
|
298
|
+
id: 'content',
|
|
299
|
+
label: 'Content',
|
|
300
|
+
to: `content/${firstPage.id}`,
|
|
301
|
+
order: 10,
|
|
302
|
+
permission: 'content.manage',
|
|
303
|
+
items: pages.map((p) => ({
|
|
304
|
+
id: `content.${p.id}`,
|
|
305
|
+
label: p.label ?? p.id,
|
|
306
|
+
to: `content/${p.id}`,
|
|
307
|
+
})),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
// Routed collections (ADR-0011) get their own collapsible nav group + one manager route each.
|
|
311
|
+
if (firstCollection) {
|
|
312
|
+
nav.push({
|
|
313
|
+
id: 'collections',
|
|
314
|
+
label: 'Collections',
|
|
315
|
+
to: `collections/${firstCollection.id}`,
|
|
316
|
+
order: 20,
|
|
317
|
+
permission: 'content.manage',
|
|
318
|
+
items: collections.map((c) => ({
|
|
319
|
+
id: `collections.${c.id}`,
|
|
320
|
+
label: c.label,
|
|
321
|
+
to: `collections/${c.id}`,
|
|
322
|
+
})),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const routes: AdminRoute[] = [
|
|
327
|
+
...pages.map((p) => ({
|
|
328
|
+
path: `content/${p.id}`,
|
|
329
|
+
component: () => <PageScreen pageId={p.id} />,
|
|
330
|
+
})),
|
|
331
|
+
...collections.map((c) => ({
|
|
332
|
+
path: `collections/${c.id}`,
|
|
333
|
+
component: () => <CollectionManager collectionId={c.id} />,
|
|
334
|
+
})),
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
return { id: 'content', nav, routes };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Re-export the editor seam so a client app can register a custom field type's admin editor by id.
|
|
341
|
+
export { registerEditor, getEditor } from './field-registry';
|
|
342
|
+
export type { AdminEditor, AdminEditorProps } from './field-registry';
|
|
343
|
+
export { FieldView } from './form-engine';
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// The content plugin's API contract (ADR-0006/0008) — fn ids + zod input schemas, shared by the server
|
|
4
|
+
// `Plugin.api` handlers and the admin client. Section structure (the editable tree) is read from the
|
|
5
|
+
// React-free section registry (./define) directly by the admin, not over the wire; this contract is only the
|
|
6
|
+
// value read/write API. The hydrated value's shape is per-section (typed via InferValue), so outputs are opaque.
|
|
7
|
+
|
|
8
|
+
export const contentFns = {
|
|
9
|
+
sectionGet: 'content.section.get',
|
|
10
|
+
sectionSave: 'content.section.save',
|
|
11
|
+
// Routed collections (ADR-0011) — the admin manager's read/write API. Public reads (list / by-slug) are SSR
|
|
12
|
+
// server-direct (exported from the package), not RPC, like the section page reads.
|
|
13
|
+
collectionList: 'content.collection.list',
|
|
14
|
+
collectionGetForEdit: 'content.collection.getForEdit',
|
|
15
|
+
collectionSave: 'content.collection.save',
|
|
16
|
+
collectionDelete: 'content.collection.delete',
|
|
17
|
+
collectionReorder: 'content.collection.reorder',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
/** Identifies a section instance: which page it sits on + which section it is. */
|
|
21
|
+
export const sectionRef = z.object({ page: z.string().min(1), section: z.string().min(1) });
|
|
22
|
+
export type SectionRefDto = z.infer<typeof sectionRef>;
|
|
23
|
+
|
|
24
|
+
/** Save payload: the section ref + the editor's value (validated server-side against the section schema). */
|
|
25
|
+
export const sectionSaveInput = sectionRef.extend({ value: z.unknown() });
|
|
26
|
+
export type SectionSaveInputDto = z.infer<typeof sectionSaveInput>;
|
|
27
|
+
|
|
28
|
+
/** List a collection's entries (admin manager). */
|
|
29
|
+
export const collectionListInput = z.object({
|
|
30
|
+
collection: z.string().min(1),
|
|
31
|
+
limit: z.number().int().positive().optional(),
|
|
32
|
+
offset: z.number().int().nonnegative().optional(),
|
|
33
|
+
});
|
|
34
|
+
export type CollectionListInputDto = z.infer<typeof collectionListInput>;
|
|
35
|
+
|
|
36
|
+
/** Identifies one collection entry by id. */
|
|
37
|
+
export const collectionItemRef = z.object({
|
|
38
|
+
collection: z.string().min(1),
|
|
39
|
+
id: z.string().min(1),
|
|
40
|
+
});
|
|
41
|
+
export type CollectionItemRefDto = z.infer<typeof collectionItemRef>;
|
|
42
|
+
|
|
43
|
+
/** Save payload: the collection + slug + editor value; `id` omitted creates, present updates. */
|
|
44
|
+
export const collectionSaveInput = z.object({
|
|
45
|
+
collection: z.string().min(1),
|
|
46
|
+
id: z.string().min(1).optional(),
|
|
47
|
+
slug: z.string(),
|
|
48
|
+
value: z.unknown(),
|
|
49
|
+
});
|
|
50
|
+
export type CollectionSaveInputDto = z.infer<typeof collectionSaveInput>;
|
|
51
|
+
|
|
52
|
+
/** Reorder payload: the collection + the entry ids in their new order. */
|
|
53
|
+
export const collectionReorderInput = z.object({
|
|
54
|
+
collection: z.string().min(1),
|
|
55
|
+
orderedIds: z.array(z.string().min(1)),
|
|
56
|
+
});
|
|
57
|
+
export type CollectionReorderInputDto = z.infer<typeof collectionReorderInput>;
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
index,
|
|
3
|
+
integer,
|
|
4
|
+
jsonb,
|
|
5
|
+
pgSchema,
|
|
6
|
+
text,
|
|
7
|
+
timestamp,
|
|
8
|
+
uniqueIndex,
|
|
9
|
+
uuid,
|
|
10
|
+
} from 'drizzle-orm/pg-core';
|
|
11
|
+
|
|
12
|
+
// Content tables live in the shared `app` schema (ADR-0003). The plugin references the schema by NAME via its
|
|
13
|
+
// own pgSchema('app') instance — it must not import @saena-io/core internals (§13); `app` itself is created by
|
|
14
|
+
// the core migration, which runs before plugin migrations (runPluginMigrations, ADR-0006).
|
|
15
|
+
const appSchema = pgSchema('app');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A section's content VALUES for one page (ADR-0008). Structure (which pages, which sections, which fields) is
|
|
19
|
+
* CODE — this table holds only the editable content: `config` is the non-text JSON (discriminants, hrefs,
|
|
20
|
+
* scalars, list item ids + order, custom-field data), and every translatable leaf lives in the i18n store as a
|
|
21
|
+
* key (`content.<page>.<section>.…`), never here. v1 is live-edit (no draft/published columns).
|
|
22
|
+
*/
|
|
23
|
+
export const contentSectionValues = appSchema.table(
|
|
24
|
+
'content_section_values',
|
|
25
|
+
{
|
|
26
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
27
|
+
/** Route id of the page this section instance belongs to (e.g. 'home', 'about'). */
|
|
28
|
+
page: text('page').notNull(),
|
|
29
|
+
/** Section id within the page composition. */
|
|
30
|
+
section: text('section').notNull(),
|
|
31
|
+
/** The section value projected to config (translatable leaves blanked — they live in i18n). */
|
|
32
|
+
config: jsonb('config').$type<unknown>().notNull().default({}),
|
|
33
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
34
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
35
|
+
},
|
|
36
|
+
(t) => [uniqueIndex('content_section_values_page_section_uq').on(t.page, t.section)],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export type ContentSectionRow = typeof contentSectionValues.$inferSelect;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A routed COLLECTION entry — one row per repeatable, individually-routed item (News article, team member,
|
|
43
|
+
* event) declared via `defineCollection` (ADR-0011). Unlike a section (one row per page slot), a collection has
|
|
44
|
+
* many rows; each carries its own `slug` (the `$slug` route segment), the blanked `config` projection, and an
|
|
45
|
+
* author-controlled `sortOrder`. Every translatable leaf lives in the i18n store rooted at the row's STABLE id
|
|
46
|
+
* (`content.collection.<collection>.<id>.…`), so reorder/rename never re-keys or orphans translations. Live-edit
|
|
47
|
+
* (no draft/published columns yet — additive when draft/publish lands).
|
|
48
|
+
*/
|
|
49
|
+
export const contentCollectionItems = appSchema.table(
|
|
50
|
+
'content_collection_items',
|
|
51
|
+
{
|
|
52
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
53
|
+
/** The `defineCollection` id this entry belongs to (e.g. 'news', 'programs'). */
|
|
54
|
+
collection: text('collection').notNull(),
|
|
55
|
+
/** URL segment for the entry's detail route; unique within a collection. */
|
|
56
|
+
slug: text('slug').notNull(),
|
|
57
|
+
/** The entry value projected to config (translatable leaves blanked — they live in i18n). */
|
|
58
|
+
config: jsonb('config').$type<unknown>().notNull().default({}),
|
|
59
|
+
/** Author-controlled order for the list/archive view. */
|
|
60
|
+
sortOrder: integer('sort_order').notNull().default(0),
|
|
61
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
62
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
63
|
+
},
|
|
64
|
+
(t) => [
|
|
65
|
+
uniqueIndex('content_collection_items_collection_slug_uq').on(t.collection, t.slug),
|
|
66
|
+
index('content_collection_items_collection_order_idx').on(t.collection, t.sortOrder),
|
|
67
|
+
],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
export type ContentCollectionRow = typeof contentCollectionItems.$inferSelect;
|
package/src/define.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { FieldDecl, InferValue } from './field';
|
|
2
|
+
|
|
3
|
+
// Section + page DEFINITIONS — the React-free half of "structure is code" (ADR-0008). A section author declares
|
|
4
|
+
// a section's id, label, and field schema here (the schema drives admin form + validation + i18n + the public
|
|
5
|
+
// value type); the section's React component is wired separately in the ./public entry, so this registry stays
|
|
6
|
+
// React-free and the server graph can read schemas without pulling React. Pages compose section ids in code.
|
|
7
|
+
|
|
8
|
+
export interface SectionDef<S extends FieldDecl = FieldDecl> {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
schema: S;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sections = new Map<string, SectionDef>();
|
|
15
|
+
|
|
16
|
+
/** Declare a content section's schema (registers it for the admin form, the server service, and key derivation). */
|
|
17
|
+
export function defineSection<S extends FieldDecl>(def: SectionDef<S>): SectionDef<S> {
|
|
18
|
+
sections.set(def.id, def);
|
|
19
|
+
return def;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getSection(id: string): SectionDef | undefined {
|
|
23
|
+
return sections.get(id);
|
|
24
|
+
}
|
|
25
|
+
export function allSections(): SectionDef[] {
|
|
26
|
+
return [...sections.values()];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The fully-typed content a section's public component receives, derived from its schema declaration. */
|
|
30
|
+
export type SectionValue<D extends SectionDef> = D extends SectionDef<infer S>
|
|
31
|
+
? InferValue<S>
|
|
32
|
+
: never;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A routed COLLECTION definition (ADR-0011) — a repeatable, individually-routed content type. Like a section,
|
|
36
|
+
* the `schema` drives the admin manager, validation, i18n, and the public value type; unlike a section, it has
|
|
37
|
+
* many entries (rows), each with its own `slug` + detail route the dev authors. React-free.
|
|
38
|
+
*/
|
|
39
|
+
export interface CollectionDef<S extends FieldDecl = FieldDecl> {
|
|
40
|
+
id: string;
|
|
41
|
+
label: string;
|
|
42
|
+
schema: S;
|
|
43
|
+
/** Optional slug auto-derivation for the admin manager (always client-overridable). `from` names a sibling
|
|
44
|
+
* field in `schema` to slugify (mirrors the `slug({ from })` field option). */
|
|
45
|
+
slug?: { from?: string };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const collections = new Map<string, CollectionDef>();
|
|
49
|
+
|
|
50
|
+
/** Declare a routed collection (registers it for the admin manager, the server service, and key derivation). */
|
|
51
|
+
export function defineCollection<S extends FieldDecl>(def: CollectionDef<S>): CollectionDef<S> {
|
|
52
|
+
collections.set(def.id, def);
|
|
53
|
+
return def;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getCollection(id: string): CollectionDef | undefined {
|
|
57
|
+
return collections.get(id);
|
|
58
|
+
}
|
|
59
|
+
export function allCollections(): CollectionDef[] {
|
|
60
|
+
return [...collections.values()];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One hydrated collection entry a public route renders: its identity + slug + the typed schema value. */
|
|
64
|
+
export interface CollectionItem<D extends CollectionDef> {
|
|
65
|
+
id: string;
|
|
66
|
+
slug: string;
|
|
67
|
+
value: D extends CollectionDef<infer S> ? InferValue<S> : never;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PageDef {
|
|
71
|
+
id: string;
|
|
72
|
+
/** Public path (e.g. '/', '/about') — the dev-authored route this page composition renders at. */
|
|
73
|
+
path: string;
|
|
74
|
+
label?: string;
|
|
75
|
+
/** Ordered section ids composing the page (fixed in code; the client never reorders). */
|
|
76
|
+
sections: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const pages = new Map<string, PageDef>();
|
|
80
|
+
|
|
81
|
+
/** Declare a page as a fixed, ordered composition of sections (the admin reads this tree read-only). */
|
|
82
|
+
export function definePage(def: PageDef): PageDef {
|
|
83
|
+
pages.set(def.id, def);
|
|
84
|
+
return def;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getPage(id: string): PageDef | undefined {
|
|
88
|
+
return pages.get(id);
|
|
89
|
+
}
|
|
90
|
+
export function allPages(): PageDef[] {
|
|
91
|
+
return [...pages.values()];
|
|
92
|
+
}
|