@orion-studios/cms 0.5.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/README.md +96 -0
- package/dist/analytics/react.d.ts +53 -0
- package/dist/analytics/react.js +195 -0
- package/dist/blocks/index.d.ts +222 -0
- package/dist/blocks/index.js +338 -0
- package/dist/chunk-HVJCF2IZ.js +76 -0
- package/dist/chunk-VPUODCNH.js +448 -0
- package/dist/chunk-WQDHEQDE.js +527 -0
- package/dist/content/index.d.ts +51 -0
- package/dist/content/index.js +8 -0
- package/dist/forms/index.d.ts +70 -0
- package/dist/forms/index.js +38 -0
- package/dist/forms/react.d.ts +45 -0
- package/dist/forms/react.js +8 -0
- package/dist/server/index.d.ts +403 -0
- package/dist/server/index.js +2280 -0
- package/dist/studio/index.d.ts +534 -0
- package/dist/studio/index.js +3824 -0
- package/dist/studio/styles.css +444 -0
- package/dist/submission-BKdBedOe.d.ts +61 -0
- package/dist/submission-CzrfXu17.d.ts +30 -0
- package/package.json +97 -0
- package/sql/bootstrap.sql +458 -0
- package/sql/migrations/0001_atomic_scheduled_publish.sql +68 -0
- package/sql/migrations/0002_rate_limits.sql +62 -0
- package/sql/migrations/0003_atomic_global_update.sql +46 -0
- package/sql/migrations/0004_analytics_visitor_tracking.sql +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# @orion-studios/cms
|
|
2
|
+
|
|
3
|
+
Orion Studios' CMS engine: a single package that turns a custom Next.js site
|
|
4
|
+
into a client-editable site. JSONB content on Supabase, a self-contained
|
|
5
|
+
Studio admin, and a block system where one definition drives everything.
|
|
6
|
+
|
|
7
|
+
- **No schema migrations for content changes.** Pages are JSONB layouts; the
|
|
8
|
+
eight `cms_` tables never change when you add or edit block types.
|
|
9
|
+
- **`defineBlock()` is the single source of truth.** A Zod schema + a React
|
|
10
|
+
component per section type produces: TypeScript types, validation, defaults,
|
|
11
|
+
the builder palette, the editing panel, inline click-to-edit, and the
|
|
12
|
+
public-site renderer.
|
|
13
|
+
- **Three runtime modes**, chosen by env: `supabase` (production),
|
|
14
|
+
`memory` (zero-setup dev/demo, in-process store), `static` (no CMS at all —
|
|
15
|
+
renders from `site-content.ts`; how sites launch before a client buys the CMS).
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @orion-studios/cms
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Next.js (App Router), React 19, Zod 3. Supabase only in supabase mode.
|
|
24
|
+
|
|
25
|
+
## Entry points
|
|
26
|
+
|
|
27
|
+
| Import | Contents |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `@orion-studios/cms/blocks` | `defineBlock`, `defineGlobal`, `createBlockRegistry`, `createGlobalRegistry`, field helpers (`link`, `mediaRef`, `paragraphs`), `BlockPreviewProps`, `BlockData` |
|
|
30
|
+
| `@orion-studios/cms/server` | `createCmsRoutes` (the whole API as one catch-all route), `getServiceClient`, `resolveUser`, `runContentSync`, `getMemoryCms`, permission matrix (`can`, roles) |
|
|
31
|
+
| `@orion-studios/cms/studio` | `Studio` (the full admin UI), `LoginView`, `PasswordInput`, `UsersView`, `createStudioApi` |
|
|
32
|
+
| `@orion-studios/cms/content` | `createContentClient` — anon-key reads for the public site (`getPageByPath`, `listPublishedPaths`, `getGlobal`) |
|
|
33
|
+
| `@orion-studios/cms/forms` | Form config types, `processSubmission` (validation, honeypot, rate limiting), shared `SiteForm` renderer |
|
|
34
|
+
| `@orion-studios/cms/sql/bootstrap.sql` | The complete idempotent schema (tables, RPCs, RLS, grants) |
|
|
35
|
+
| `@orion-studios/cms/studio/styles.css` | Studio styles (self-contained, `ost-` prefixed) |
|
|
36
|
+
|
|
37
|
+
## The 5-minute wiring (already done in the site template)
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// src/app/api/cms/[...path]/route.ts — the entire CMS API
|
|
41
|
+
import { createCmsRoutes } from '@orion-studios/cms/server'
|
|
42
|
+
import { registry } from '@/cms/registry'
|
|
43
|
+
export const { GET, POST, PATCH, DELETE } = createCmsRoutes({
|
|
44
|
+
registry, syncToken: process.env.CMS_SYNC_TOKEN,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// src/app/studio/page.tsx — the admin
|
|
48
|
+
import { Studio } from '@orion-studios/cms/studio'
|
|
49
|
+
import '@orion-studios/cms/studio/styles.css'
|
|
50
|
+
export default function Page() {
|
|
51
|
+
return <Studio registry={registry} globals={globals} siteName="Client Co" logoUrl="/logo.png" />
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Don't start here for a new site — copy `templates/site` from the
|
|
56
|
+
`orion-cms-packages` repo instead; it has all of this wired plus the scripts
|
|
57
|
+
(`bootstrap`, `create-admin`, `sync`). See `NEW-SITE-PLAYBOOK.md` at the repo
|
|
58
|
+
root for the full procedure.
|
|
59
|
+
|
|
60
|
+
## Roles
|
|
61
|
+
|
|
62
|
+
Four tiers, enforced in the API (`src/server/permissions.ts`), UI adapts:
|
|
63
|
+
|
|
64
|
+
- `content` — edit text/images on existing sections (no add/remove/reorder)
|
|
65
|
+
- `editor` — full content control: structure, new pages, publish
|
|
66
|
+
- `admin` — everything + user management (Users panel in the Studio)
|
|
67
|
+
- `developer` — full access; the tier Orion Studios keeps for itself
|
|
68
|
+
|
|
69
|
+
Rank rules: you can only assign roles at or below your own, never change your
|
|
70
|
+
own role, never delete yourself.
|
|
71
|
+
|
|
72
|
+
## Environment
|
|
73
|
+
|
|
74
|
+
| Variable | Where | Purpose |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `NEXT_PUBLIC_SUPABASE_URL` | local + Vercel | project URL (also selects supabase mode) |
|
|
77
|
+
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | local + Vercel | public reads (RLS: published content only) |
|
|
78
|
+
| `SUPABASE_SERVICE_ROLE_KEY` | local + Vercel | server-only; powers the API layer |
|
|
79
|
+
| `DATABASE_URL` | local + Vercel | Postgres for bootstrap (use the transaction pooler on Vercel) |
|
|
80
|
+
| `CMS_SYNC_TOKEN` | local + Vercel | authorizes `POST /api/cms/sync` |
|
|
81
|
+
| `CMS_STATIC=true` | optional | static mode (no CMS) |
|
|
82
|
+
| `CMS_MEMORY=true` | optional | force memory mode |
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm run typecheck && npm test # 24 node:test suites
|
|
88
|
+
npm run build # tsup → dist/
|
|
89
|
+
npm pack # tarball for vendoring / inspection
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The SQL bootstrap is idempotent and runs under restricted Postgres roles: it
|
|
93
|
+
avoids `auth`-schema privileges and explicitly grants the PostgREST roles
|
|
94
|
+
(`service_role`, `anon`, `authenticated`) table access — RLS policies filter
|
|
95
|
+
rows but do not grant access, and Supabase's default privileges only cover
|
|
96
|
+
`postgres`-created tables.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site-side analytics tracker: small, first-party, and zero dependency.
|
|
3
|
+
* Mount <Analytics /> once in the site layout. It captures:
|
|
4
|
+
*
|
|
5
|
+
* - pageviews on every App Router route change (+ referrer/UTM on entry)
|
|
6
|
+
* - clicks via one document-level listener: tel: ("call"), mailto: ("email"),
|
|
7
|
+
* the configured portal host ("portal"), other external links ("outbound"),
|
|
8
|
+
* and anything carrying data-analytics="name" ("cta")
|
|
9
|
+
* - form funnel events emitted by FormRenderer through the window bridge
|
|
10
|
+
*
|
|
11
|
+
* Events queue in memory and flush with sendBeacon so a phone tap that
|
|
12
|
+
* navigates away is never lost. Visiting /studio flags the browser and
|
|
13
|
+
* permanently excludes it (the owner's own clicks don't pollute the data).
|
|
14
|
+
*/
|
|
15
|
+
declare const EXCLUDE_FLAG = "orion-analytics-exclude";
|
|
16
|
+
declare const ANALYTICS_CONSENT_COOKIE = "orion_analytics_consent";
|
|
17
|
+
declare const ANALYTICS_VISITOR_COOKIE = "orion_visitor_id";
|
|
18
|
+
declare const ANALYTICS_CONSENT_EVENT = "orion-analytics-consent";
|
|
19
|
+
type AnalyticsConsent = 'accepted' | 'declined' | null;
|
|
20
|
+
type QueuedEvent = {
|
|
21
|
+
type: 'pageview' | 'click' | 'form' | 'not_found';
|
|
22
|
+
name?: string;
|
|
23
|
+
path: string;
|
|
24
|
+
referrer?: string;
|
|
25
|
+
utm?: Record<string, string>;
|
|
26
|
+
meta?: Record<string, unknown>;
|
|
27
|
+
};
|
|
28
|
+
declare global {
|
|
29
|
+
interface Window {
|
|
30
|
+
__orionTrack?: (type: QueuedEvent['type'], name: string, meta?: Record<string, unknown>) => void;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
type AnalyticsProps = {
|
|
34
|
+
/** Hostnames counted as "portal" clicks (e.g. the client-portal domain). */
|
|
35
|
+
portalHosts?: string[];
|
|
36
|
+
/** Skip visitors with Do Not Track enabled. Off by default. */
|
|
37
|
+
respectDNT?: boolean;
|
|
38
|
+
/** Wait for an explicit accepted consent cookie before collecting events. */
|
|
39
|
+
requireConsent?: boolean;
|
|
40
|
+
/** Add a pseudonymous first-party visitor cookie for multi-day analytics. */
|
|
41
|
+
visitorCookie?: boolean;
|
|
42
|
+
/** Defaults to /api/cms. */
|
|
43
|
+
basePath?: string;
|
|
44
|
+
};
|
|
45
|
+
declare function getAnalyticsConsent(): AnalyticsConsent;
|
|
46
|
+
declare function setAnalyticsConsent(value: Exclude<AnalyticsConsent, null>): void;
|
|
47
|
+
declare function Analytics({ portalHosts, respectDNT, requireConsent, visitorCookie, basePath, }: AnalyticsProps): null;
|
|
48
|
+
/** Mount inside the site's not-found page to record 404 hits. */
|
|
49
|
+
declare function AnalyticsNotFound(): null;
|
|
50
|
+
/** Programmatic tracking for custom site components. No-op when analytics is off. */
|
|
51
|
+
declare function trackEvent(name: string, meta?: Record<string, unknown>): void;
|
|
52
|
+
|
|
53
|
+
export { ANALYTICS_CONSENT_COOKIE, ANALYTICS_CONSENT_EVENT, ANALYTICS_VISITOR_COOKIE, Analytics, type AnalyticsConsent, AnalyticsNotFound, type AnalyticsProps, EXCLUDE_FLAG, getAnalyticsConsent, setAnalyticsConsent, trackEvent };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
// src/analytics/react.tsx
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { usePathname } from "next/navigation";
|
|
7
|
+
var EXCLUDE_FLAG = "orion-analytics-exclude";
|
|
8
|
+
var ANALYTICS_CONSENT_COOKIE = "orion_analytics_consent";
|
|
9
|
+
var ANALYTICS_VISITOR_COOKIE = "orion_visitor_id";
|
|
10
|
+
var ANALYTICS_CONSENT_EVENT = "orion-analytics-consent";
|
|
11
|
+
var readCookie = (name) => {
|
|
12
|
+
if (typeof document === "undefined") return "";
|
|
13
|
+
const prefix = `${encodeURIComponent(name)}=`;
|
|
14
|
+
const match = document.cookie.split("; ").find((entry) => entry.startsWith(prefix));
|
|
15
|
+
return match ? decodeURIComponent(match.slice(prefix.length)) : "";
|
|
16
|
+
};
|
|
17
|
+
var writeCookie = (name, value, maxAge) => {
|
|
18
|
+
if (typeof document === "undefined") return;
|
|
19
|
+
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
|
20
|
+
document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secure}`;
|
|
21
|
+
};
|
|
22
|
+
function getAnalyticsConsent() {
|
|
23
|
+
const value = readCookie(ANALYTICS_CONSENT_COOKIE);
|
|
24
|
+
return value === "accepted" || value === "declined" ? value : null;
|
|
25
|
+
}
|
|
26
|
+
function setAnalyticsConsent(value) {
|
|
27
|
+
writeCookie(ANALYTICS_CONSENT_COOKIE, value, 365 * 24 * 60 * 60);
|
|
28
|
+
if (value === "declined") writeCookie(ANALYTICS_VISITOR_COOKIE, "", 0);
|
|
29
|
+
window.dispatchEvent(new CustomEvent(ANALYTICS_CONSENT_EVENT, { detail: value }));
|
|
30
|
+
}
|
|
31
|
+
var visitorId = () => {
|
|
32
|
+
const existing = readCookie(ANALYTICS_VISITOR_COOKIE);
|
|
33
|
+
if (/^[0-9a-f-]{36}$/i.test(existing)) return existing;
|
|
34
|
+
const created = crypto.randomUUID();
|
|
35
|
+
writeCookie(ANALYTICS_VISITOR_COOKIE, created, 365 * 24 * 60 * 60);
|
|
36
|
+
return created;
|
|
37
|
+
};
|
|
38
|
+
var isExcluded = (respectDNT) => {
|
|
39
|
+
try {
|
|
40
|
+
if (localStorage.getItem(EXCLUDE_FLAG)) return true;
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
43
|
+
if (respectDNT && navigator.doNotTrack === "1") return true;
|
|
44
|
+
return false;
|
|
45
|
+
};
|
|
46
|
+
function Analytics({
|
|
47
|
+
portalHosts = [],
|
|
48
|
+
respectDNT = false,
|
|
49
|
+
requireConsent = false,
|
|
50
|
+
visitorCookie = false,
|
|
51
|
+
basePath = "/api/cms"
|
|
52
|
+
}) {
|
|
53
|
+
const pathname = usePathname();
|
|
54
|
+
const [consent, setConsentState] = useState(null);
|
|
55
|
+
const queueRef = useRef([]);
|
|
56
|
+
const firstLoadRef = useRef(true);
|
|
57
|
+
const excludedRef = useRef(false);
|
|
58
|
+
const visitorIdRef = useRef("");
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setConsentState(getAnalyticsConsent());
|
|
61
|
+
const onConsent = (event) => {
|
|
62
|
+
setConsentState(event.detail);
|
|
63
|
+
};
|
|
64
|
+
window.addEventListener(ANALYTICS_CONSENT_EVENT, onConsent);
|
|
65
|
+
return () => window.removeEventListener(ANALYTICS_CONSENT_EVENT, onConsent);
|
|
66
|
+
}, []);
|
|
67
|
+
const flush = () => {
|
|
68
|
+
const queue = queueRef.current;
|
|
69
|
+
if (queue.length === 0) return;
|
|
70
|
+
queueRef.current = [];
|
|
71
|
+
const body = JSON.stringify({
|
|
72
|
+
events: queue,
|
|
73
|
+
...visitorIdRef.current ? { visitorId: visitorIdRef.current } : {}
|
|
74
|
+
});
|
|
75
|
+
const url = `${basePath}/events`;
|
|
76
|
+
try {
|
|
77
|
+
if (navigator.sendBeacon && navigator.sendBeacon(url, new Blob([body], { type: "application/json" }))) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
fetch(url, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "content-type": "application/json" },
|
|
85
|
+
body,
|
|
86
|
+
keepalive: true
|
|
87
|
+
}).catch(() => void 0);
|
|
88
|
+
};
|
|
89
|
+
const track = (event) => {
|
|
90
|
+
if (excludedRef.current) return;
|
|
91
|
+
queueRef.current.push(event);
|
|
92
|
+
if (queueRef.current.length >= 10) flush();
|
|
93
|
+
};
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const consentGranted = !requireConsent || consent === "accepted";
|
|
96
|
+
excludedRef.current = !consentGranted || isExcluded(respectDNT);
|
|
97
|
+
if (excludedRef.current) {
|
|
98
|
+
queueRef.current = [];
|
|
99
|
+
visitorIdRef.current = "";
|
|
100
|
+
delete window.__orionTrack;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
visitorIdRef.current = visitorCookie && consent === "accepted" ? visitorId() : "";
|
|
104
|
+
window.__orionTrack = (type, name, meta) => {
|
|
105
|
+
track({ type, name, path: window.location.pathname, meta });
|
|
106
|
+
if (type === "form" || type === "not_found") flush();
|
|
107
|
+
};
|
|
108
|
+
const onClick = (event) => {
|
|
109
|
+
const target = event.target;
|
|
110
|
+
const tagged = target?.closest("[data-analytics]");
|
|
111
|
+
const anchor = target?.closest("a");
|
|
112
|
+
let name = "";
|
|
113
|
+
let meta;
|
|
114
|
+
if (tagged?.dataset.analytics) {
|
|
115
|
+
name = "cta";
|
|
116
|
+
meta = { label: tagged.dataset.analytics.slice(0, 100) };
|
|
117
|
+
} else if (anchor?.href) {
|
|
118
|
+
const href = anchor.getAttribute("href") || "";
|
|
119
|
+
if (href.startsWith("tel:")) name = "call";
|
|
120
|
+
else if (href.startsWith("mailto:")) name = "email";
|
|
121
|
+
else {
|
|
122
|
+
try {
|
|
123
|
+
const url = new URL(anchor.href);
|
|
124
|
+
if (url.origin !== window.location.origin) {
|
|
125
|
+
name = portalHosts.includes(url.hostname) ? "portal" : "outbound";
|
|
126
|
+
meta = { href: url.hostname };
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!name) return;
|
|
133
|
+
track({ type: "click", name, path: window.location.pathname, meta });
|
|
134
|
+
flush();
|
|
135
|
+
};
|
|
136
|
+
const onHide = () => {
|
|
137
|
+
if (document.visibilityState === "hidden") flush();
|
|
138
|
+
};
|
|
139
|
+
document.addEventListener("click", onClick, { capture: true, passive: true });
|
|
140
|
+
document.addEventListener("visibilitychange", onHide);
|
|
141
|
+
window.addEventListener("pagehide", flush);
|
|
142
|
+
const interval = window.setInterval(flush, 8e3);
|
|
143
|
+
return () => {
|
|
144
|
+
document.removeEventListener("click", onClick, { capture: true });
|
|
145
|
+
document.removeEventListener("visibilitychange", onHide);
|
|
146
|
+
window.removeEventListener("pagehide", flush);
|
|
147
|
+
window.clearInterval(interval);
|
|
148
|
+
delete window.__orionTrack;
|
|
149
|
+
};
|
|
150
|
+
}, [respectDNT, requireConsent, visitorCookie, consent, basePath, portalHosts.join(",")]);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!pathname || excludedRef.current) return;
|
|
153
|
+
const event = { type: "pageview", path: pathname };
|
|
154
|
+
if (firstLoadRef.current) {
|
|
155
|
+
firstLoadRef.current = false;
|
|
156
|
+
if (document.referrer) {
|
|
157
|
+
try {
|
|
158
|
+
if (new URL(document.referrer).origin !== window.location.origin) {
|
|
159
|
+
event.referrer = document.referrer;
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const params = new URLSearchParams(window.location.search);
|
|
165
|
+
const utm = {};
|
|
166
|
+
for (const key of ["source", "medium", "campaign", "term", "content"]) {
|
|
167
|
+
const value = params.get(`utm_${key}`);
|
|
168
|
+
if (value) utm[key] = value;
|
|
169
|
+
}
|
|
170
|
+
if (Object.keys(utm).length > 0) event.utm = utm;
|
|
171
|
+
}
|
|
172
|
+
track(event);
|
|
173
|
+
}, [pathname, consent, requireConsent]);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
function AnalyticsNotFound() {
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
window.__orionTrack?.("not_found", "", { referrer: document.referrer.slice(0, 200) });
|
|
179
|
+
}, []);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
function trackEvent(name, meta) {
|
|
183
|
+
if (typeof window !== "undefined") window.__orionTrack?.("click", name, meta);
|
|
184
|
+
}
|
|
185
|
+
export {
|
|
186
|
+
ANALYTICS_CONSENT_COOKIE,
|
|
187
|
+
ANALYTICS_CONSENT_EVENT,
|
|
188
|
+
ANALYTICS_VISITOR_COOKIE,
|
|
189
|
+
Analytics,
|
|
190
|
+
AnalyticsNotFound,
|
|
191
|
+
EXCLUDE_FLAG,
|
|
192
|
+
getAnalyticsConsent,
|
|
193
|
+
setAnalyticsConsent,
|
|
194
|
+
trackEvent
|
|
195
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { ComponentType } from 'react';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Editor field derivation: turns a block's Zod schema into a default editor
|
|
6
|
+
* field list so blocks are editable with zero editor configuration. Explicit
|
|
7
|
+
* `editor.fields` entries in defineBlock() override the derived ones per key.
|
|
8
|
+
*/
|
|
9
|
+
type EditorInput = 'text' | 'textarea' | 'number' | 'checkbox' | 'select' | 'link' | 'media' | 'file' | 'paragraphs' | 'stringList' | 'itemList';
|
|
10
|
+
type EditorField = {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
input: EditorInput;
|
|
14
|
+
/** Editable inline in the preview (text/textarea only). */
|
|
15
|
+
inline?: boolean;
|
|
16
|
+
options?: Array<{
|
|
17
|
+
label: string;
|
|
18
|
+
value: string;
|
|
19
|
+
}>;
|
|
20
|
+
/** For itemList: fields of each item, derived recursively. */
|
|
21
|
+
itemFields?: EditorField[];
|
|
22
|
+
/** For itemList: a new-item template from the item schema's defaults. */
|
|
23
|
+
itemTemplate?: Record<string, unknown>;
|
|
24
|
+
/** For itemList: item property used as the collapsed label. */
|
|
25
|
+
itemLabelKey?: string;
|
|
26
|
+
};
|
|
27
|
+
declare const labelForKey: (key: string) => string;
|
|
28
|
+
|
|
29
|
+
/** A call-to-action link: label + href + visual variant. */
|
|
30
|
+
declare function link(): z.ZodObject<{
|
|
31
|
+
label: z.ZodDefault<z.ZodString>;
|
|
32
|
+
href: z.ZodDefault<z.ZodString>;
|
|
33
|
+
variant: z.ZodDefault<z.ZodEnum<["solid", "outline", "line"]>>;
|
|
34
|
+
}, "strip", z.ZodTypeAny, {
|
|
35
|
+
label: string;
|
|
36
|
+
href: string;
|
|
37
|
+
variant: "solid" | "outline" | "line";
|
|
38
|
+
}, {
|
|
39
|
+
label?: string | undefined;
|
|
40
|
+
href?: string | undefined;
|
|
41
|
+
variant?: "solid" | "outline" | "line" | undefined;
|
|
42
|
+
}>;
|
|
43
|
+
type LinkValue = z.infer<ReturnType<typeof link>>;
|
|
44
|
+
/**
|
|
45
|
+
* A media reference. `mediaId` points at cms_media; `src` is a resolved
|
|
46
|
+
* fallback path (e.g. a public/ asset) used when no media doc is linked.
|
|
47
|
+
* Renderers prefer the transform URL of `mediaId` when present.
|
|
48
|
+
*/
|
|
49
|
+
declare function mediaRef(): z.ZodObject<{
|
|
50
|
+
mediaId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
51
|
+
src: z.ZodDefault<z.ZodString>;
|
|
52
|
+
alt: z.ZodDefault<z.ZodString>;
|
|
53
|
+
caption: z.ZodDefault<z.ZodString>;
|
|
54
|
+
}, "strip", z.ZodTypeAny, {
|
|
55
|
+
mediaId: string | null;
|
|
56
|
+
src: string;
|
|
57
|
+
alt: string;
|
|
58
|
+
caption: string;
|
|
59
|
+
}, {
|
|
60
|
+
mediaId?: string | null | undefined;
|
|
61
|
+
src?: string | undefined;
|
|
62
|
+
alt?: string | undefined;
|
|
63
|
+
caption?: string | undefined;
|
|
64
|
+
}>;
|
|
65
|
+
type MediaRefValue = z.infer<ReturnType<typeof mediaRef>>;
|
|
66
|
+
/** Multi-paragraph text stored as a string array (one entry per paragraph). */
|
|
67
|
+
declare function paragraphs(): z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
68
|
+
/**
|
|
69
|
+
* A non-image file reference (PDF service agreement, price sheet, …).
|
|
70
|
+
* `src` is the resolved public URL; `filename` is the display name.
|
|
71
|
+
*/
|
|
72
|
+
declare function fileRef(): z.ZodObject<{
|
|
73
|
+
mediaId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
74
|
+
src: z.ZodDefault<z.ZodString>;
|
|
75
|
+
filename: z.ZodDefault<z.ZodString>;
|
|
76
|
+
}, "strip", z.ZodTypeAny, {
|
|
77
|
+
mediaId: string | null;
|
|
78
|
+
src: string;
|
|
79
|
+
filename: string;
|
|
80
|
+
}, {
|
|
81
|
+
mediaId?: string | null | undefined;
|
|
82
|
+
src?: string | undefined;
|
|
83
|
+
filename?: string | undefined;
|
|
84
|
+
}>;
|
|
85
|
+
type FileRefValue = z.infer<ReturnType<typeof fileRef>>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Globals follow the same single-source-of-truth pattern as blocks: one
|
|
89
|
+
* `defineGlobal()` call yields the schema, defaults, validation, and the
|
|
90
|
+
* Studio's editor fields.
|
|
91
|
+
*/
|
|
92
|
+
type GlobalDefinition<TSchema extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>> = {
|
|
93
|
+
key: string;
|
|
94
|
+
label: string;
|
|
95
|
+
description?: string;
|
|
96
|
+
schema: TSchema;
|
|
97
|
+
editor: {
|
|
98
|
+
fields: EditorField[];
|
|
99
|
+
};
|
|
100
|
+
defaultData: z.infer<TSchema>;
|
|
101
|
+
};
|
|
102
|
+
type DefineGlobalArgs<TSchema extends z.ZodObject<z.ZodRawShape>> = {
|
|
103
|
+
key: string;
|
|
104
|
+
label?: string;
|
|
105
|
+
description?: string;
|
|
106
|
+
schema: TSchema;
|
|
107
|
+
editor?: {
|
|
108
|
+
fields: Array<Partial<EditorField> & {
|
|
109
|
+
key: string;
|
|
110
|
+
}>;
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
declare function defineGlobal<TSchema extends z.ZodObject<z.ZodRawShape>>(args: DefineGlobalArgs<TSchema>): GlobalDefinition<TSchema>;
|
|
114
|
+
type AnyGlobalDefinition = GlobalDefinition<any>;
|
|
115
|
+
type GlobalRegistry = {
|
|
116
|
+
list(): GlobalDefinition[];
|
|
117
|
+
get(key: string): GlobalDefinition | undefined;
|
|
118
|
+
/** Parses stored data through the schema (defaults applied, unknown keys stripped). */
|
|
119
|
+
normalize(key: string, data: unknown): Record<string, unknown>;
|
|
120
|
+
};
|
|
121
|
+
declare function createGlobalRegistry(globals: ReadonlyArray<AnyGlobalDefinition>): GlobalRegistry;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The Orion CMS block system.
|
|
125
|
+
*
|
|
126
|
+
* One `defineBlock()` call is the single source of truth for a section type.
|
|
127
|
+
* Everything else — TypeScript types, default data, server-side validation,
|
|
128
|
+
* the builder palette, the inspector fields, and rendering — derives from it.
|
|
129
|
+
*/
|
|
130
|
+
/** A block instance as stored inside a page's JSONB layout. */
|
|
131
|
+
type BlockInstance<TData = Record<string, unknown>> = {
|
|
132
|
+
id: string;
|
|
133
|
+
type: string;
|
|
134
|
+
data: TData;
|
|
135
|
+
/** Presentation settings (spacing, background, visibility) — builder-owned. */
|
|
136
|
+
settings?: Record<string, unknown>;
|
|
137
|
+
};
|
|
138
|
+
type PageLayout = BlockInstance[];
|
|
139
|
+
type BlockPreviewProps<TData> = {
|
|
140
|
+
data: TData;
|
|
141
|
+
/** True when rendering inside the Studio builder (enables inline editing affordances). */
|
|
142
|
+
editing?: boolean;
|
|
143
|
+
/** Inline-commit callback available in the builder. */
|
|
144
|
+
onChange?: (patch: Partial<TData>) => void;
|
|
145
|
+
};
|
|
146
|
+
type BlockDefinition<TSchema extends z.ZodObject<z.ZodRawShape> = z.ZodObject<z.ZodRawShape>> = {
|
|
147
|
+
type: string;
|
|
148
|
+
label: string;
|
|
149
|
+
description?: string;
|
|
150
|
+
/** Palette grouping in the builder. */
|
|
151
|
+
category?: string;
|
|
152
|
+
schema: TSchema;
|
|
153
|
+
/** Derived from the schema; entries here override per key and set order. */
|
|
154
|
+
editor: {
|
|
155
|
+
fields: EditorField[];
|
|
156
|
+
};
|
|
157
|
+
defaultData: z.infer<TSchema>;
|
|
158
|
+
preview?: ComponentType<BlockPreviewProps<z.infer<TSchema>>>;
|
|
159
|
+
};
|
|
160
|
+
type DefineBlockArgs<TSchema extends z.ZodObject<z.ZodRawShape>> = {
|
|
161
|
+
type: string;
|
|
162
|
+
label?: string;
|
|
163
|
+
description?: string;
|
|
164
|
+
category?: string;
|
|
165
|
+
schema: TSchema;
|
|
166
|
+
editor?: {
|
|
167
|
+
fields: Array<Partial<EditorField> & {
|
|
168
|
+
key: string;
|
|
169
|
+
}>;
|
|
170
|
+
};
|
|
171
|
+
preview?: ComponentType<BlockPreviewProps<z.infer<TSchema>>>;
|
|
172
|
+
};
|
|
173
|
+
declare function defineBlock<TSchema extends z.ZodObject<z.ZodRawShape>>(args: DefineBlockArgs<TSchema>): BlockDefinition<TSchema>;
|
|
174
|
+
type ValidationIssue = {
|
|
175
|
+
blockId: string;
|
|
176
|
+
blockType: string;
|
|
177
|
+
path: string;
|
|
178
|
+
message: string;
|
|
179
|
+
};
|
|
180
|
+
type BlockRegistry = {
|
|
181
|
+
list(): BlockDefinition[];
|
|
182
|
+
get(type: string): BlockDefinition | undefined;
|
|
183
|
+
has(type: string): boolean;
|
|
184
|
+
/** Palette entries for the builder's Add Section panel. */
|
|
185
|
+
palette(): Array<{
|
|
186
|
+
type: string;
|
|
187
|
+
label: string;
|
|
188
|
+
description?: string;
|
|
189
|
+
category?: string;
|
|
190
|
+
defaultData: Record<string, unknown>;
|
|
191
|
+
}>;
|
|
192
|
+
/**
|
|
193
|
+
* Validates and normalizes a layout: unknown block types are rejected,
|
|
194
|
+
* each block's data is parsed through its schema (applying defaults,
|
|
195
|
+
* stripping unknown keys), and issues are collected per block.
|
|
196
|
+
*/
|
|
197
|
+
validateLayout(layout: unknown): {
|
|
198
|
+
ok: boolean;
|
|
199
|
+
layout: PageLayout;
|
|
200
|
+
issues: ValidationIssue[];
|
|
201
|
+
};
|
|
202
|
+
/** Creates a new block instance of the given type with schema defaults. */
|
|
203
|
+
createInstance(type: string): BlockInstance;
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* Registry input is intentionally loose on the schema generic: block
|
|
207
|
+
* definitions carry per-block data types for authoring, while the registry
|
|
208
|
+
* operates on the erased form (preview props are contravariant).
|
|
209
|
+
*/
|
|
210
|
+
type AnyBlockDefinition = BlockDefinition<any>;
|
|
211
|
+
declare function createBlockRegistry(blocks: ReadonlyArray<AnyBlockDefinition>): BlockRegistry;
|
|
212
|
+
/** Infer the data type of a block definition: `BlockData<typeof pageHero>`. */
|
|
213
|
+
type BlockData<TDefinition> = TDefinition extends BlockDefinition<infer TSchema> ? z.infer<TSchema> : never;
|
|
214
|
+
/**
|
|
215
|
+
* Typed content-as-code authoring: builds a block instance from a definition
|
|
216
|
+
* with compile-time checking and schema validation (defaults applied).
|
|
217
|
+
*
|
|
218
|
+
* blockInstance(pageHero, { title: 'Welcome' })
|
|
219
|
+
*/
|
|
220
|
+
declare function blockInstance<TSchema extends z.ZodObject<z.ZodRawShape>>(definition: BlockDefinition<TSchema>, data?: Partial<z.infer<TSchema>>, id?: string): BlockInstance<z.infer<TSchema>>;
|
|
221
|
+
|
|
222
|
+
export { type AnyBlockDefinition, type AnyGlobalDefinition, type BlockData, type BlockDefinition, type BlockInstance, type BlockPreviewProps, type BlockRegistry, type DefineBlockArgs, type DefineGlobalArgs, type EditorField, type EditorInput, type FileRefValue, type GlobalDefinition, type GlobalRegistry, type LinkValue, type MediaRefValue, type PageLayout, type ValidationIssue, blockInstance, createBlockRegistry, createGlobalRegistry, defineBlock, defineGlobal, fileRef, labelForKey, link, mediaRef, paragraphs };
|