@velora-cms/plugin-sdk 0.9.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,370 @@
1
+ import type { ComponentType } from "react";
2
+ import type { CreateDocumentTypeRequest } from "@velora-cms/api-schemas";
3
+ import type { ThemeTokenOverrides } from "@velora-cms/design-tokens";
4
+ import type { ZodType } from "zod";
5
+ import type { DataTypePlugin } from "./data-type-plugin.js";
6
+ import type { PluginApiRouteContext, PublishedContent, ScopedStructuredStorage } from "./structured-storage-types.js";
7
+ /**
8
+ * The seven plugin types. A plugin's type determines which contributions
9
+ * it may declare — see the contribution matrix in docs/PROJECT_CONTEXT.md
10
+ * (Plugin System), which the per-type manifest variants below encode
11
+ * structurally: TypeScript rejects a disallowed contribution at compile
12
+ * time, and the Zod manifest validation (Session 60) enforces the same
13
+ * matrix at runtime for non-TS authors.
14
+ */
15
+ export type PluginType = "datatype" | "section" | "template" | "theme" | "integration" | "utility" | "bundle";
16
+ /** The types whose contribution-matrix rows can carry browser-rendered
17
+ * contributions (views, widgets, settings pages, toolbar/tree items) —
18
+ * exactly these ship a Module Federation client bundle
19
+ * (remoteEntry.js). theme/template/bundle are data-only: their packages
20
+ * carry manifest.json + server/plugin.js (the manifest carrier) and
21
+ * nothing client-side. Single source of truth for velora-plugin's
22
+ * build/validate, apps/server's install path, and (as a documented
23
+ * static mirror) the marketplace's submission checks. */
24
+ export declare const CLIENT_BUNDLE_TYPES: readonly ["datatype", "section", "integration", "utility"];
25
+ export declare function hasClientBundle(type: PluginType): boolean;
26
+ /**
27
+ * Databases a plugin can declare support for. Deliberately NOT
28
+ * db-adapter's DatabaseDialect: that union includes 'mock' (the in-memory
29
+ * test adapter), which no plugin may target — and the SDK is a published
30
+ * package that must not depend on the unpublished adapter internals.
31
+ */
32
+ export type DatabaseDialect = "postgresql" | "mysql" | "sqlite" | "mssql" | "mongodb";
33
+ /**
34
+ * How the plugin stores data and where it can run. Required on every
35
+ * manifest (plugin-sdk.md) — the marketplace pre-filters by the site's
36
+ * active database and install is blocked with a clear reason, never a
37
+ * silent failure.
38
+ */
39
+ export interface DatabaseCompatibility {
40
+ /** 'simple' = JSON key-value SimpleStorageAPI (default, ~80% of
41
+ * plugins); 'structured' = relational StructuredStorageAPI, which
42
+ * requires needsStructuredData and manual marketplace review. */
43
+ storageType: "simple" | "structured";
44
+ supported: DatabaseDialect[];
45
+ /** Optional human-readable reason per UNSUPPORTED dialect (shown on the
46
+ * blocked-install screen) — hence Partial: supported dialects carry no
47
+ * reason. */
48
+ unsupportedReason?: Partial<Record<DatabaseDialect, string>>;
49
+ }
50
+ /**
51
+ * Capability declarations surfaced to the user at install time (and, for
52
+ * needsStructuredData, triggering manual marketplace review). Required on
53
+ * every manifest — validation rejects a manifest missing it.
54
+ */
55
+ export interface PluginPermissions {
56
+ needsStructuredData: boolean;
57
+ needsExternalNetwork: boolean;
58
+ needsFileSystemAccess: boolean;
59
+ }
60
+ /**
61
+ * The storage surface a plugin migration runs against. Deliberately empty
62
+ * for now: plugins never construct raw SQL (non-negotiable), so this is
63
+ * NOT db-adapter's TransactionContext — Month 6's plugin-storage work
64
+ * fills it in (defineTable etc.). Widening a parameter the plugin
65
+ * receives is backward-compatible for plugin authors.
66
+ */
67
+ export interface PluginMigrationContext {
68
+ }
69
+ /** A database change applied at plugin install/update time. */
70
+ export interface PluginMigration {
71
+ id: string;
72
+ name: string;
73
+ up: (ctx: PluginMigrationContext) => void | Promise<void>;
74
+ down: (ctx: PluginMigrationContext) => void | Promise<void>;
75
+ }
76
+ /** Result of a plugin's validateMigration hook. */
77
+ export interface PluginMigrationValidation {
78
+ compatible: boolean;
79
+ issues: string[];
80
+ }
81
+ /**
82
+ * Participation in a site-wide dialect migration (e.g. SQLite →
83
+ * PostgreSQL, the v1.x wizard): a plugin with its own stored data can
84
+ * veto, transform rows in flight, and run before/after steps.
85
+ */
86
+ export interface PluginMigrationHooks {
87
+ beforeMigration?: (from: DatabaseDialect, to: DatabaseDialect) => void | Promise<void>;
88
+ transformRow?: (table: string, row: Record<string, unknown>, from: DatabaseDialect, to: DatabaseDialect) => Record<string, unknown> | Promise<Record<string, unknown>>;
89
+ afterMigration?: (from: DatabaseDialect, to: DatabaseDialect) => void | Promise<void>;
90
+ validateMigration?: (from: DatabaseDialect, to: DatabaseDialect) => PluginMigrationValidation | Promise<PluginMigrationValidation>;
91
+ }
92
+ /** The events a plugin can hook. The hook engine (S159) actually FIRES
93
+ * only three of these — content:afterPublish (including rollbacks),
94
+ * content:afterTrash, content:afterRestore — with the
95
+ * ContentLifecycleHookPayload shape declared below; every other event in
96
+ * this catalog is declared for forward compatibility and stays inert. */
97
+ export type HookEvent = "content:beforeSave" | "content:afterSave" | "content:beforePublish" | "content:afterPublish" | "content:afterTrash" | "content:afterRestore" | "content:beforeDelete" | "media:afterUpload" | "user:afterLogin" | "api:beforeResponse";
98
+ export interface ContentLifecycleHookPayload {
99
+ event: "content:afterPublish" | "content:afterTrash" | "content:afterRestore";
100
+ contentId: string;
101
+ path: string;
102
+ documentTypeId: string;
103
+ occurredAt: string;
104
+ }
105
+ export interface PluginHookContext {
106
+ structuredStorage: ScopedStructuredStorage;
107
+ getPublishedContent(contentId: string): Promise<PublishedContent | null>;
108
+ }
109
+ export type HookHandler = (payload: unknown, context?: PluginHookContext) => void | Promise<void>;
110
+ export type PluginHooks = Partial<Record<HookEvent, HookHandler>>;
111
+ /** A major admin feature area with its own app-bar section (e.g. the
112
+ * Commerce plugin's Orders). First consumer: Month 8. */
113
+ export interface AdminSectionContribution {
114
+ id: string;
115
+ name: string;
116
+ icon: string;
117
+ view: ComponentType;
118
+ /** When true, only admin-role users see this section in the app bar and
119
+ * may reach its route — the same gate built-in Settings/Users use
120
+ * (Session 91 closed the plugin bypass in Session 108). Default (unset)
121
+ * = visible to any authenticated user. */
122
+ adminOnly?: boolean;
123
+ }
124
+ export interface DashboardWidgetContribution {
125
+ id: string;
126
+ name: string;
127
+ view: ComponentType;
128
+ }
129
+ /** Mounted under /api/plugins/:pluginId/… so two plugins can never
130
+ * collide on a path (api-design.md) — the host dispatches to this exact
131
+ * (method, path) pair at request time (Month 8, Session 89: the first
132
+ * real server-side consumer). `handler` stays untyped at the SDK
133
+ * boundary (same cross-boundary erasure as view ComponentType fields)
134
+ * — the host casts to real Fastify types internally, since it always
135
+ * passes real FastifyRequest/FastifyReply instances. */
136
+ export interface ApiRouteContribution {
137
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
138
+ /** Relative to /api/plugins/:pluginId/ — must start with "/". */
139
+ path: string;
140
+ /** Who may call this route.
141
+ * - "admin" (the default when omitted) requires a JWT admin session —
142
+ * for admin-facing plugin UIs like Commerce Orders or the Forms
143
+ * builder.
144
+ * - "apiKey" requires a valid public API key — for credentialed
145
+ * headless callers like a storefront checkout.
146
+ * - "public" requires NO credentials — for genuinely anonymous
147
+ * browser traffic such as a contact-form submission. Session 108
148
+ * deliberately deferred this tier ("a webhook/public level can be
149
+ * added when a real consumer needs it"); Session 160's Forms
150
+ * plugin is that consumer. A public route owns its own abuse
151
+ * controls (honeypot, per-IP bucket, body caps) and only reaches
152
+ * browsers cross-origin when the operator allowlists their origin
153
+ * in VELORA_CORS_ORIGINS.
154
+ * Anything unrecognized falls back to "admin" — secure by default. */
155
+ access?: "admin" | "apiKey" | "public";
156
+ /** Optional request-side validation. The handler's response is NOT
157
+ * schema-validated — trusted, same as SimpleStorageAPI's handlers
158
+ * today. */
159
+ schema?: {
160
+ body?: ZodType;
161
+ querystring?: ZodType;
162
+ params?: ZodType;
163
+ };
164
+ /** Third argument added Month 8, Session 90 — the first server-side
165
+ * consumer needing privileged host access. Existing two-argument (or
166
+ * fewer) handlers remain perfectly valid: TypeScript allows a function
167
+ * with fewer declared parameters to satisfy a type expecting more. */
168
+ handler: (request: unknown, reply: unknown, context: PluginApiRouteContext) => unknown;
169
+ }
170
+ export interface ScheduledTaskContribution {
171
+ id: string;
172
+ name: string;
173
+ /** Standard 5-field cron expression. */
174
+ cron: string;
175
+ run: () => void | Promise<void>;
176
+ }
177
+ /** Media pipeline processor (thumbnails, optimization). The file/result
178
+ * contract firms up when the BullMQ media pipeline lands. */
179
+ export interface MediaProcessorContribution {
180
+ id: string;
181
+ name: string;
182
+ process: (file: unknown) => unknown | Promise<unknown>;
183
+ }
184
+ export interface SettingsPageContribution {
185
+ id: string;
186
+ name: string;
187
+ view: ComponentType;
188
+ }
189
+ export interface ContentTreeActionContribution {
190
+ id: string;
191
+ label: string;
192
+ onSelect: (nodeId: string) => void | Promise<void>;
193
+ }
194
+ /** A button in the rich-text editor's toolbar. TipTap wiring comes with
195
+ * the first consumer. */
196
+ export interface EditorToolbarContribution {
197
+ id: string;
198
+ label: string;
199
+ icon: string;
200
+ onClick: () => void | Promise<void>;
201
+ }
202
+ /** A service an integration plugin exposes for other plugins to consume
203
+ * (e.g. a Stripe client). Shape intentionally opaque until the
204
+ * cross-plugin service registry exists. */
205
+ export interface ExposedServiceContribution {
206
+ id: string;
207
+ name: string;
208
+ service: unknown;
209
+ }
210
+ /** A ready-made frontend starter shipped alongside a plugin (e.g. the
211
+ * Commerce plugin's "Online Store" Next.js starter) — one marketplace
212
+ * listing, working site immediately. Packaging lands Month 13. */
213
+ export interface BundledTemplateContribution {
214
+ name: string;
215
+ description?: string;
216
+ /** Where the starter's source lives until marketplace packaging exists. */
217
+ repository?: string;
218
+ }
219
+ export interface SerializerContext {
220
+ /** The public core item, post field-serialization. */
221
+ item: {
222
+ id: string;
223
+ documentTypeId: string;
224
+ locale: string;
225
+ publishedAt: string | null;
226
+ version: number;
227
+ };
228
+ /** SERIALIZED field data — contributions run after core serialization. */
229
+ data: Record<string, unknown>;
230
+ documentType: {
231
+ id: string;
232
+ name: string;
233
+ };
234
+ }
235
+ export interface CustomFormatContribution {
236
+ /** Plugin-scoped name, /^[a-z0-9-]+$/ — addressed as `<pluginId>:<name>`. */
237
+ name: string;
238
+ build: (context: SerializerContext) => unknown | Promise<unknown>;
239
+ }
240
+ export interface SerializerContribution {
241
+ extendContentResponse?: (context: SerializerContext) => Record<string, unknown> | Promise<Record<string, unknown>>;
242
+ customFormats?: CustomFormatContribution[];
243
+ }
244
+ export interface DataTypePluginContributions {
245
+ /** The Month 3 datatype contract, referenced verbatim — a datatype
246
+ * plugin can ship several field types (id/version on each entry are the
247
+ * datatype's own, distinct from the plugin's). */
248
+ dataTypes?: DataTypePlugin[];
249
+ mediaProcessors?: MediaProcessorContribution[];
250
+ settingsPages?: SettingsPageContribution[];
251
+ }
252
+ export interface SectionPluginContributions {
253
+ dataTypes?: DataTypePlugin[];
254
+ adminSections?: AdminSectionContribution[];
255
+ dashboardWidgets?: DashboardWidgetContribution[];
256
+ apiRoutes?: ApiRouteContribution[];
257
+ hooks?: PluginHooks;
258
+ scheduledTasks?: ScheduledTaskContribution[];
259
+ settingsPages?: SettingsPageContribution[];
260
+ /** Document types the plugin provisions at install — the creation shape
261
+ * (server mints ids), same schema the admin's builder POSTs. */
262
+ documentTypes?: CreateDocumentTypeRequest[];
263
+ bundledTemplate?: BundledTemplateContribution;
264
+ }
265
+ export interface TemplatePluginContributions {
266
+ /** A template plugin IS a bundled template (per the matrix), so there
267
+ * is no separate bundledTemplate key — it contributes the document
268
+ * types its starter needs. */
269
+ documentTypes?: CreateDocumentTypeRequest[];
270
+ }
271
+ export interface ThemePluginContributions {
272
+ /** Tokens only, never components — the same ThemeTokenOverrides shape
273
+ * the built-in Light/Dark themes use (color scales + radius, enforced
274
+ * structurally by @velora-cms/design-tokens). */
275
+ themeTokens?: ThemeTokenOverrides;
276
+ }
277
+ export interface IntegrationPluginContributions {
278
+ hooks?: PluginHooks;
279
+ settingsPages?: SettingsPageContribution[];
280
+ exposedServices?: ExposedServiceContribution[];
281
+ serializers?: SerializerContribution;
282
+ }
283
+ export interface UtilityPluginContributions {
284
+ dashboardWidgets?: DashboardWidgetContribution[];
285
+ hooks?: PluginHooks;
286
+ scheduledTasks?: ScheduledTaskContribution[];
287
+ mediaProcessors?: MediaProcessorContribution[];
288
+ settingsPages?: SettingsPageContribution[];
289
+ contentTreeActions?: ContentTreeActionContribution[];
290
+ editorToolbar?: EditorToolbarContribution[];
291
+ serializers?: SerializerContribution;
292
+ }
293
+ export interface BundlePluginContributions {
294
+ /** Plugin ids installed together — resolved by the marketplace. */
295
+ bundledPlugins?: string[];
296
+ bundledTemplate?: BundledTemplateContribution;
297
+ }
298
+ /** Fields shared by every plugin type. */
299
+ export interface PluginManifestBase {
300
+ /** Reverse domain, e.g. 'com.yourname.googlemaps'. */
301
+ id: string;
302
+ name: string;
303
+ /** The plugin's own semver. */
304
+ version: string;
305
+ type: PluginType;
306
+ description: string;
307
+ author: string;
308
+ license: string;
309
+ /** Marketplace price; 0 or absent = free. */
310
+ price?: number;
311
+ /**
312
+ * Marketplace verification hash. Optional BY DESIGN, not oversight:
313
+ * per the Trust model, sideloaded/local dev plugins are unsigned by
314
+ * definition (Tier 2 'unverified') — only marketplace-signed plugins
315
+ * carry one. Signing is authenticity, not a sandbox; the safety layer
316
+ * is identical for both tiers.
317
+ */
318
+ signature?: string;
319
+ /** Semver range of compatible CMS versions, e.g. '^1.0.0'. */
320
+ cmsVersion: string;
321
+ /** Ids of plugins this one requires. */
322
+ pluginDependencies?: string[];
323
+ databaseCompatibility: DatabaseCompatibility;
324
+ permissions: PluginPermissions;
325
+ /** DB changes applied on install (through plugin storage — never raw
326
+ * SQL). */
327
+ migrations?: PluginMigration[];
328
+ onInstall?: () => void | Promise<void>;
329
+ onUninstall?: () => void | Promise<void>;
330
+ onActivate?: () => void | Promise<void>;
331
+ onDeactivate?: () => void | Promise<void>;
332
+ onUpdate?: (previousVersion: string) => void | Promise<void>;
333
+ migrationHooks?: PluginMigrationHooks;
334
+ }
335
+ export interface DataTypePluginManifest extends PluginManifestBase {
336
+ type: "datatype";
337
+ contributions: DataTypePluginContributions;
338
+ }
339
+ export interface SectionPluginManifest extends PluginManifestBase {
340
+ type: "section";
341
+ contributions: SectionPluginContributions;
342
+ }
343
+ export interface TemplatePluginManifest extends PluginManifestBase {
344
+ type: "template";
345
+ contributions: TemplatePluginContributions;
346
+ }
347
+ export interface ThemePluginManifest extends PluginManifestBase {
348
+ type: "theme";
349
+ contributions: ThemePluginContributions;
350
+ }
351
+ export interface IntegrationPluginManifest extends PluginManifestBase {
352
+ type: "integration";
353
+ contributions: IntegrationPluginContributions;
354
+ }
355
+ export interface UtilityPluginManifest extends PluginManifestBase {
356
+ type: "utility";
357
+ contributions: UtilityPluginContributions;
358
+ }
359
+ export interface BundlePluginManifest extends PluginManifestBase {
360
+ type: "bundle";
361
+ contributions: BundlePluginContributions;
362
+ }
363
+ /**
364
+ * The full plugin manifest — a discriminated union on `type`, so the
365
+ * contribution matrix is enforced by the compiler for TypeScript authors
366
+ * (a 'datatype' plugin declaring adminSections is a type error, not a
367
+ * runtime surprise). Built-ins and Module Federation remotes both
368
+ * register through this same contract.
369
+ */
370
+ export type CMSPlugin = DataTypePluginManifest | SectionPluginManifest | TemplatePluginManifest | ThemePluginManifest | IntegrationPluginManifest | UtilityPluginManifest | BundlePluginManifest;
@@ -0,0 +1,12 @@
1
+ /** The types whose contribution-matrix rows can carry browser-rendered
2
+ * contributions (views, widgets, settings pages, toolbar/tree items) —
3
+ * exactly these ship a Module Federation client bundle
4
+ * (remoteEntry.js). theme/template/bundle are data-only: their packages
5
+ * carry manifest.json + server/plugin.js (the manifest carrier) and
6
+ * nothing client-side. Single source of truth for velora-plugin's
7
+ * build/validate, apps/server's install path, and (as a documented
8
+ * static mirror) the marketplace's submission checks. */
9
+ export const CLIENT_BUNDLE_TYPES = ["datatype", "section", "integration", "utility"];
10
+ export function hasClientBundle(type) {
11
+ return CLIENT_BUNDLE_TYPES.includes(type);
12
+ }
@@ -0,0 +1,30 @@
1
+ import { type ReactNode } from "react";
2
+ interface PluginValueProviderProps {
3
+ value: unknown;
4
+ onChange: (value: unknown) => void;
5
+ children: ReactNode;
6
+ }
7
+ export declare function PluginValueProvider({ value, onChange, children }: PluginValueProviderProps): import("react").JSX.Element;
8
+ export declare function usePluginValue<T = unknown>(): {
9
+ value: T;
10
+ onChange: (value: T) => void;
11
+ };
12
+ interface PluginSettingsProviderProps {
13
+ settings: Record<string, unknown>;
14
+ onSettingsChange?: (settings: Record<string, unknown>) => void;
15
+ children: ReactNode;
16
+ }
17
+ export declare function PluginSettingsProvider({ settings, onSettingsChange, children }: PluginSettingsProviderProps): import("react").JSX.Element;
18
+ export declare function usePluginSettings<T extends Record<string, unknown> = Record<string, unknown>>(): T;
19
+ export declare function usePluginSettingsValue<T extends Record<string, unknown> = Record<string, unknown>>(): {
20
+ value: T;
21
+ onChange: (value: T) => void;
22
+ };
23
+ export type PluginFetch = (input: string, init?: RequestInit) => Promise<Response>;
24
+ interface PluginApiProviderProps {
25
+ fetch: PluginFetch;
26
+ children: ReactNode;
27
+ }
28
+ export declare function PluginApiProvider({ fetch, children }: PluginApiProviderProps): import("react").JSX.Element;
29
+ export declare function usePluginApi(): PluginFetch;
30
+ export {};
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ const PluginValueContext = createContext(null);
4
+ export function PluginValueProvider({ value, onChange, children }) {
5
+ return _jsx(PluginValueContext.Provider, { value: { value, onChange }, children: children });
6
+ }
7
+ // A view calling this outside a host-rendered PluginValueProvider is a
8
+ // real bug (the host forgot to wrap it) — throw loudly, don't hand back
9
+ // a silently-undefined value/no-op onChange.
10
+ export function usePluginValue() {
11
+ const ctx = useContext(PluginValueContext);
12
+ if (!ctx) {
13
+ throw new Error("usePluginValue must be called from a view rendered inside a PluginValueProvider");
14
+ }
15
+ return ctx;
16
+ }
17
+ const PluginSettingsContext = createContext(null);
18
+ export function PluginSettingsProvider({ settings, onSettingsChange, children }) {
19
+ return (_jsx(PluginSettingsContext.Provider, { value: { settings, onSettingsChange }, children: children }));
20
+ }
21
+ // Returns settings directly (not {settings, onChange}) — this is the
22
+ // READ-ONLY path used by input/readOnly/preview views (Session 69). For a
23
+ // settings-EDITING view (the "settings" DataTypePluginViews slot), use
24
+ // usePluginSettingsValue() below instead.
25
+ export function usePluginSettings() {
26
+ const ctx = useContext(PluginSettingsContext);
27
+ if (!ctx) {
28
+ throw new Error("usePluginSettings must be called from a view rendered inside a PluginSettingsProvider");
29
+ }
30
+ return ctx.settings;
31
+ }
32
+ // Mirrors usePluginValue()'s {value, onChange} shape, for settings-EDITING
33
+ // views only (Session 69) — the host (e.g. the Data Types editor) must
34
+ // supply onSettingsChange via PluginSettingsProvider, or this throws:
35
+ // a settings view rendered in a read-only context is a host bug, same
36
+ // "throw loudly" philosophy as usePluginValue().
37
+ export function usePluginSettingsValue() {
38
+ const ctx = useContext(PluginSettingsContext);
39
+ if (!ctx) {
40
+ throw new Error("usePluginSettingsValue must be called from a view rendered inside a PluginSettingsProvider");
41
+ }
42
+ if (!ctx.onSettingsChange) {
43
+ throw new Error("usePluginSettingsValue must be called from a settings view rendered inside a PluginSettingsProvider " +
44
+ "that supplies onSettingsChange (read-only contexts should use usePluginSettings() instead)");
45
+ }
46
+ return { value: ctx.settings, onChange: ctx.onSettingsChange };
47
+ }
48
+ const PluginApiContext = createContext(null);
49
+ export function PluginApiProvider({ fetch, children }) {
50
+ return _jsx(PluginApiContext.Provider, { value: fetch, children: children });
51
+ }
52
+ export function usePluginApi() {
53
+ const ctx = useContext(PluginApiContext);
54
+ if (!ctx) {
55
+ throw new Error("usePluginApi must be called from a view rendered inside a PluginApiProvider");
56
+ }
57
+ return ctx;
58
+ }
@@ -0,0 +1,4 @@
1
+ import type { DataTypePlugin } from "./data-type-plugin.js";
2
+ export declare function registerDataType(plugin: DataTypePlugin): void;
3
+ export declare function getDataType(id: string): DataTypePlugin | undefined;
4
+ export declare function listDataTypes(): DataTypePlugin[];
@@ -0,0 +1,17 @@
1
+ // Static registry: built-ins register into this exact same map at module
2
+ // load (Core vs Plugin — built-ins are statically registered, dynamically
3
+ // loaded marketplace plugins are a second registration path into this same
4
+ // registry, not a different kind of plugin).
5
+ const dataTypes = new Map();
6
+ export function registerDataType(plugin) {
7
+ if (dataTypes.has(plugin.id)) {
8
+ throw new Error(`A datatype with id "${plugin.id}" is already registered`);
9
+ }
10
+ dataTypes.set(plugin.id, plugin);
11
+ }
12
+ export function getDataType(id) {
13
+ return dataTypes.get(id);
14
+ }
15
+ export function listDataTypes() {
16
+ return [...dataTypes.values()];
17
+ }
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ interface SimpleStorageValueProviderProps {
3
+ contentId: string;
4
+ fieldId: string;
5
+ pluginId: string;
6
+ children: ReactNode;
7
+ }
8
+ export declare function SimpleStorageValueProvider({ contentId, fieldId, pluginId, children, }: SimpleStorageValueProviderProps): import("react").JSX.Element;
9
+ export {};
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { PluginValueProvider, usePluginApi } from "./plugin-context.js";
4
+ // The only SDK code path that ever hits /api/plugins/:pluginId/storage/...
5
+ // (SimpleStorageAPI, Session 68) — a plugin's view code (and usePluginValue()
6
+ // itself) never touches the storage API directly, matching plugin-sdk.md's
7
+ // "SDK handles all host communication" rule. Fetches the current value on
8
+ // mount and feeds it into the SAME PluginValueContext usePluginValue()
9
+ // already reads, so a view wrapped in this provider is indistinguishable
10
+ // from one wrapped in a plain PluginValueProvider — swap this in wherever a
11
+ // field's value should live in content_field_values instead of the local
12
+ // content blob (see document-type-form.tsx for the blob-backed default).
13
+ //
14
+ // Shape mirrors views/image-view.tsx's useMediaPreview(): usePluginApi(),
15
+ // a cancelled-guarded effect, local value/error state. A GET returning
16
+ // {value: null} is the normal "never set" state, not an error.
17
+ export function SimpleStorageValueProvider({ contentId, fieldId, pluginId, children, }) {
18
+ const fetchApi = usePluginApi();
19
+ const [value, setValue] = useState(null);
20
+ const [error, setError] = useState(null);
21
+ const path = `/api/plugins/${pluginId}/storage/${contentId}/${fieldId}`;
22
+ useEffect(() => {
23
+ let cancelled = false;
24
+ setError(null);
25
+ fetchApi(path)
26
+ .then(async (response) => {
27
+ if (!response.ok)
28
+ throw new Error(`Failed to load plugin value (${response.status})`);
29
+ const body = (await response.json());
30
+ if (!cancelled)
31
+ setValue(body.value);
32
+ })
33
+ .catch((err) => {
34
+ if (!cancelled)
35
+ setError(err instanceof Error ? err.message : String(err));
36
+ });
37
+ return () => {
38
+ cancelled = true;
39
+ };
40
+ }, [fetchApi, path]);
41
+ function onChange(next) {
42
+ setValue(next); // optimistic — the view should feel instant
43
+ fetchApi(path, {
44
+ method: "PUT",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ value: next }),
47
+ })
48
+ .then((response) => {
49
+ if (!response.ok)
50
+ throw new Error(`Failed to save plugin value (${response.status})`);
51
+ setError(null);
52
+ })
53
+ .catch((err) => {
54
+ setError(err instanceof Error ? err.message : String(err));
55
+ });
56
+ }
57
+ return (_jsxs(_Fragment, { children: [error && (_jsx("p", { role: "alert", className: "text-xs text-danger-600", children: error })), _jsx(PluginValueProvider, { value: value, onChange: onChange, children: children })] }));
58
+ }
@@ -0,0 +1,53 @@
1
+ export type PluginColumnType = "text" | "integer" | "boolean" | "json" | "timestamp";
2
+ export interface PluginColumnSchema {
3
+ name: string;
4
+ type: PluginColumnType;
5
+ primaryKey?: boolean;
6
+ notNull?: boolean;
7
+ unique?: boolean;
8
+ }
9
+ export interface PluginIndexSchema {
10
+ columns: string[];
11
+ unique?: boolean;
12
+ }
13
+ export interface PluginTableSchema {
14
+ tableName: string;
15
+ columns: PluginColumnSchema[];
16
+ indexes?: PluginIndexSchema[];
17
+ }
18
+ export interface StructuredQueryBuilder<T = Record<string, unknown>> extends PromiseLike<T[]> {
19
+ where(conditions: Record<string, unknown>): this;
20
+ orderBy(column: string, direction?: "asc" | "desc"): this;
21
+ limit(n: number): this;
22
+ paginate(options: {
23
+ cursor?: string;
24
+ limit: number;
25
+ }): this;
26
+ }
27
+ export interface StructuredStorageTransaction {
28
+ insert(tableName: string, data: Record<string, unknown>): Promise<void>;
29
+ update(tableName: string, where: Record<string, unknown>, data: Record<string, unknown>): Promise<number>;
30
+ delete(tableName: string, where: Record<string, unknown>): Promise<number>;
31
+ query<T = Record<string, unknown>>(tableName: string): StructuredQueryBuilder<T>;
32
+ }
33
+ export interface ScopedStructuredStorage {
34
+ defineTable(schema: PluginTableSchema): Promise<void>;
35
+ insert(tableName: string, data: Record<string, unknown>): Promise<void>;
36
+ update(tableName: string, where: Record<string, unknown>, data: Record<string, unknown>): Promise<number>;
37
+ delete(tableName: string, where: Record<string, unknown>): Promise<number>;
38
+ query<T = Record<string, unknown>>(tableName: string): StructuredQueryBuilder<T>;
39
+ transaction(fn: (tx: StructuredStorageTransaction) => Promise<void>): Promise<void>;
40
+ }
41
+ export interface PublishedContent {
42
+ id: string;
43
+ documentTypeId: string;
44
+ data: Record<string, unknown>;
45
+ }
46
+ export interface PluginApiRouteContext {
47
+ structuredStorage: ScopedStructuredStorage;
48
+ /** Null if the content id doesn't exist OR isn't published (a
49
+ * draft-only save never satisfies this) — deliberately conflates
50
+ * "missing" and "not yet purchasable," since callers treat both the
51
+ * same way. */
52
+ getPublishedContent(contentId: string): Promise<PublishedContent | null>;
53
+ }
@@ -0,0 +1,11 @@
1
+ // Local, SDK-owned duplicates of packages/db-adapter's structured-storage
2
+ // shapes and apps/server's StructuredStorageAPI/StructuredQueryBuilder/
3
+ // StructuredStorageTransaction classes — never imported directly, since
4
+ // @velora-cms/plugin-sdk is a published package that must not depend on
5
+ // unpublished adapter/server internals (same reasoning manifest.ts's
6
+ // DatabaseDialect duplication already uses). TypeScript's structural
7
+ // typing means the real apps/server classes satisfy these interfaces
8
+ // without any cast, as long as their member shapes match (they do —
9
+ // this is exactly the public contract apps/docs/src/content/docs/v1/plugin-sdk/storage.md
10
+ // already documents).
11
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare function ImageInputView(): import("react").JSX.Element;
2
+ export declare function ImageSettingsView(): import("react").JSX.Element;
3
+ export declare function ImageReadOnlyView(): import("react").JSX.Element;