@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 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 };