registerBlocksProvider, registerComponentAllowlist, media handler, form handler, presets, modifiers, inspector properties.
The SDK ships zero PageHub-specific endpoints, upload pipelines, or block libraries baked in. Anything the host app needs to own (storage, auth, custom block catalog, form posts, data fetching) goes through a runtime registry: the host calls register*() once at boot, the SDK calls back into it as needed.
All registries follow the same contract:
null (or calling reset*() where provided).Import everything below from @pagehub/sdk.
| Registry | When to call | Use for |
|---|---|---|
registerBlocksProvider | Before features.blocksPanel.enabled = true | Custom block library — REST, static JSON, in-memory fixture |
registerComponentAllowlist | Before mount | Constrained editor modes (email, kiosk) — opt-in component set |
registerCatalogFilter | Before mount | Strip presets/modifiers per component (e.g. drop backdrop-blur for email) |
registerMediaUploadHandler | Before any media UI renders | Image / file upload pipeline |
registerMediaUploadAccept | Before file pickers render | Override the <input accept> MIME list |
registerSubmissionHandler | Before viewer / form-bearing pages mount | Form submission persistence (POST /api/submissions, etc.) |
registerClientDataFetcher | Before viewer / data-bound pages mount | Client-side data sources (Stripe, Shopify, customer orders, etc.) |
registerPresets | Module-load (usually self-registered by preset file) | Add named variants for a component |
registerModifiers | Module-load | Add modifier groups (size, intent, etc.) for a component |
registerProperties | Before mount | Add custom inspector properties to the settings sidebar |
registerSectionDef | Before mount | Add custom inspector sections |
registerBlocksProviderReplaces the source for the editor's Blocks panel (toolbox "Blocks" tab — categories + searchable drag-to-canvas templates). The default provider hits /api/v1/components* on the host origin; standalone consumers ship their own.
import { registerBlocksProvider, type BlocksProvider } from "@pagehub/sdk";
const myProvider: BlocksProvider = {
async listCategories() {
// Return one entry per top-level category with counts.
return [
{ id: "hero", name: "Hero", total: 4, subcategories: [], styles: [] },
{ id: "footer", name: "Footer", total: 2, subcategories: [], styles: [] },
];
},
async listBlocks(query) {
// query: { category?, subcategory?, style?, search?, limit?, sort?, includeStructure? }
const res = await fetch(`/my-blocks?cat=${query.category ?? ""}`);
return res.json(); // BlockItem[] — structure must be a resolved JSON object
},
};
registerBlocksProvider(myProvider);
Types: BlocksProvider, BlocksProviderQuery, BlockCategory, BlockSubcategory, BlockItem.
Helpers:
defaultHttpBlocksProvider — the legacy /api/v1/components* provider, exported in case you want to compose / fall back to it.resetBlocksProvider() — restore the default.getBlocksProvider() — read the active provider.Notes:
BlockItem.structure can be a plain CraftJS-shaped object OR a JSON string the SDK will parse. The default provider also decompresses LZ-base64 strings transparently.listCategories() is called once per editor mount; listBlocks() is called per filter change (debounced by the SDK).features.blocksPanel.enabled (in PageHubFeatures) to actually show the panel.registerComponentAllowlistRestricts the toolbox + AI/MCP integrations to a named subset of components. The inverse of features.restrictedComponents (deny-list). Useful when building from a near-empty editor — email mode, kiosk mode, single-purpose embedded editor.
import { registerComponentAllowlist } from "@pagehub/sdk";
// Email mode: only allow the components an email client can render
registerComponentAllowlist(["Container", "Text", "Image", "Button"]);
Helpers:
resetComponentAllowlist() — clear and fall back to no filter.isComponentAllowed(name) — boolean check (always true when no allowlist set).getComponentAllowlist() — read the active Set<string> (or null).Notes:
resetComponentAllowlist()).registerCatalogFilterPer-component predicate that strips entries from a component's preset / modifier list at read time. The built-in catalog is left intact — only the read result is trimmed.
import { registerCatalogFilter } from "@pagehub/sdk";
// Email mode: strip any preset whose CSS uses backdrop-blur, transform, animation
registerCatalogFilter("Container", (entry, kind) => {
const css = JSON.stringify(entry);
if (/backdrop-blur|transform|animation/.test(css)) return false;
return true;
});
Signature: registerCatalogFilter(componentName: string, predicate: (entry, kind: "preset" | "modifier") => boolean)
Helpers:
resetCatalogFilter(name?) — clear one component or all.getCatalogFilter(name) — read the active predicate.registerMediaUploadHandlerThe host-supplied implementation of "upload a file somewhere and return a URL." Called by every upload site in the SDK — Media Manager, AI image gen, image picker, inline upload input, image crop.
import {
registerMediaUploadHandler,
type MediaUploadHandler,
} from "@pagehub/sdk";
const handler: MediaUploadHandler = async ({ file, signal }) => {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: fd, signal });
if (!res.ok) throw new Error("upload failed");
const { id, url } = await res.json();
return {
mediaId: id,
destination: "cf-images", // "cf-images" | "r2" — drives delete routing
deliveryURL: url,
};
};
registerMediaUploadHandler(handler);
Notes:
MediaUploadError (from @pagehub/sdk errors module) for a typed code + message.destination is metadata the host owns — the SDK passes it back on delete so you can route to the right backend.registerMediaUploadAcceptSets the <input accept> MIME / extension list for new uploads. The SDK has no concept of plan tiers or allowed-media policy — that lives in the host.
import { registerMediaUploadAccept } from "@pagehub/sdk";
registerMediaUploadAccept(
() =>
"image/png,image/jpeg,image/webp,image/svg+xml,video/mp4,application/pdf"
);
Notes:
DEFAULT_IMAGE_ACCEPT (the image-only Cloudflare Images list).MediaItem.metadata.contentType — this provider only governs new uploads.registerSubmissionHandlerWhere the Form component posts on submit. The SDK ships no default — without a handler, submissions are silently dropped.
import { registerSubmissionHandler } from "@pagehub/sdk";
registerSubmissionHandler(async (submission, settings, additional) => {
await fetch("/api/submissions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ submission, settings, additional }),
});
});
Notes:
settings includes the form node's id, name, redirect URL, success message, etc.additional carries integration metadata (e.g. site id, page id) the host may need.registerClientDataFetcherBridges Data nodes with dataSource: { provider, collection, ... } to the host's actual API when no server-side fetch ran (or when the binding refetches client-side via stateInputs).
import { registerClientDataFetcher } from "@pagehub/sdk";
registerClientDataFetcher(async (provider, collection, options) => {
const params = new URLSearchParams(options as any);
const res = await fetch(
`/api/connectors/public-data?provider=${provider}&collection=${collection}&${params}`
);
if (!res.ok) return null;
const data = await res.json();
return data.items as any[]; // return null to signal "no data" / fall back
});
Signature: (provider: string, collection: string, options?: Record<string, any>) => Promise<any[] | null>
Notes:
Data node needs client-side data.options carries dataSource.stateInputs resolved values (query, category, page, sort, etc.) — your endpoint should treat them as filter params.null (not []) when you want the SDK to fall back to whatever SSR data it already has.mountUrlQueryStateBridge() so URL params automatically flow into stateInputs.registerPresets / registerModifiersCatalog entries for a component — named variants (presets) or grouped modifier chips (modifiers) shown in the inspector. Built-in components self-register at module load via their <Component>.presets.tsx / .modifiers.ts files; you can layer additional entries on top.
import { registerPresets, registerModifiers } from "@pagehub/sdk";
registerPresets("Container", [
{
label: "Glass card",
rootClasses: "backdrop-blur-md bg-base-100/60 border border-base-300/40",
},
// ...more presets
]);
registerModifiers("Button", [
{
name: "intent",
label: "Intent",
options: [
{ name: "primary", label: "Primary", classes: "btn-primary" },
{ name: "danger", label: "Danger", classes: "btn-error" },
],
},
]);
Notes:
label; each modifier requires name + label (validated at register time, throws on missing).registerCatalogFilter to subtract entries from a built-in catalog without replacing it.registerProperties / registerSectionDefAdd custom property inputs to the shared cross-component tabs in the inspector sidebar (Layout, Background, Animation, Advanced, or your own custom tabs). Properties belong to a section; sections render in the order they were registered.
Not the same as
def.propsindefineComponent.def.propspopulates a single component's own "Content" tab — see registration-host.md.registerPropertiespopulates the shared tabs that appear on every component. The two surfaces don't overlap and can be used together on the same component.
import {
registerSectionDef,
registerProperties,
type PropertyDef,
} from "@pagehub/sdk";
registerSectionDef({
id: "myAnalytics",
label: "Analytics",
defaultOpen: false,
});
const props: PropertyDef[] = [
{
id: "trackingId",
section: "myAnalytics",
label: "Tracking ID",
input: "text", // built-in input type
appliesTo: ["Button", "Link"], // which components show this
},
];
registerProperties(props);
Helpers:
unregisterProperty(id) / unregisterSectionDef(id) — remove.getProperties() / searchProperties(query) — read.overrideProperty(id, patch) — modify a built-in.Notes:
appliesTo is a component-name allowlist; omit to show on all components.registerCustomInput (separate API in chrome/toolbar/inspector/customInputs).Every SDK throw that a host can meaningfully catch extends PageHubError. Match against the class for broad recovery, against error.code for fine-grained branching. Raw Error is reserved for internal asserts ("should never happen") that hosts can't act on.
import { PageHubError, BlocksProviderError } from "@pagehub/sdk";
try {
registerBlocksProvider(myProvider);
} catch (e) {
if (e instanceof BlocksProviderError && e.code === "BLOCKS_PROVIDER_INVALID") {
// show "your provider is missing listCategories/listBlocks" UI
} else if (e instanceof PageHubError) {
console.warn("[host] PageHub registration failed:", e.code, e.message, e.hint);
} else {
throw e;
}
}
PageHubError (base)
ConfigError — PageHub.init() / <PageHubEditor> / viewer config.ComponentDefinitionError — defineComponent() schema + processor duplicate-name check.CatalogRegistryError — registerPresets / registerModifiers / registerCatalogFilter.BlocksProviderError — registerBlocksProvider.MediaUploadError — upload pipeline (preserves the lowercase code field for backwards compat; the canonical SCREAMING_SNAKE_CASE code is MEDIA_*).SaveConflictError / SaveEmptyError / SaveFailedError — save coordinator.| Code | Thrown by | Trigger |
|---|---|---|
CONFIG_CONTAINER_NOT_FOUND | PageHub.init(), PageHubViewer.init() | config.container selector matched no DOM node |
CONFIG_CALLBACKS_REQUIRED | PageHub.init(), <PageHubEditor> standalone | config.callbacks (or callbacks prop) missing |
CONFIG_ONSAVE_INVALID | PageHub.init() | callbacks.onSave is not a function |
CONFIG_ONLOAD_INVALID | PageHub.init() | callbacks.onLoad is not a function |
COMPONENT_NAME_REQUIRED | defineComponent() | def.name missing or not a string |
COMPONENT_NAME_INVALID | defineComponent() | def.name is not PascalCase |
COMPONENT_NAME_BUILTIN_COLLISION | defineComponent() | def.name collides with a built-in component |
COMPONENT_NAME_DUPLICATE | processForEditor() | Two defineComponent calls share the same name |
COMPONENT_MISSING_COMPONENT | defineComponent() | def.component missing or not a React component |
COMPONENT_INVALID_TOHTML | defineComponent() | def.toHTML present but not a function |
COMPONENT_PROP_MISSING_TYPE | defineComponent() | A prop schema is missing type |
COMPONENT_PROP_MISSING_LABEL | defineComponent() | A prop schema is missing label |
COMPONENT_PROP_SLIDER_MISSING_BOUNDS | defineComponent() | type: "slider" prop missing min / max |
CATALOG_FILTER_INVALID_NAME | registerCatalogFilter() | componentName not a non-empty string |
CATALOG_FILTER_INVALID_PREDICATE | registerCatalogFilter() | predicate not a function |
CATALOG_PRESETS_INVALID_NAME | registerPresets() | componentName not a non-empty string |
CATALOG_PRESETS_NOT_ARRAY | registerPresets() | presets is not an array |
CATALOG_PRESET_INVALID_ENTRY | registerPresets() | A preset entry is not an object |
CATALOG_PRESET_MISSING_LABEL | registerPresets() | A preset entry is missing required string label |
CATALOG_MODIFIERS_INVALID_NAME | registerModifiers() | componentName not a non-empty string |
CATALOG_MODIFIERS_NOT_ARRAY | registerModifiers() | modifiers is not an array |
CATALOG_MODIFIER_INVALID_ENTRY | registerModifiers() | A modifier entry is not an object |
CATALOG_MODIFIER_MISSING_NAME | registerModifiers() | A modifier entry is missing required string name |
CATALOG_MODIFIER_MISSING_LABEL | registerModifiers() | A modifier entry is missing required string label |
BLOCKS_PROVIDER_INVALID | registerBlocksProvider() | Provider missing listCategories / listBlocks functions |
STATIC_RENDER_PARSE_FAILED | renderToHTML() | json arg failed to parse as SerializedNodes |
MEDIA_TOO_LARGE | upload pipeline | File exceeds plan or CF Images cap (HTTP 413) |
MEDIA_BAD_MIME | upload pipeline | MIME not in allowlist / CF rejected (HTTP 415) |
MEDIA_QUOTA | upload pipeline | Storage quota exhausted |
MEDIA_CF_REJECTED | upload pipeline | Cloudflare direct_upload returned non-OK without a more specific status |
MEDIA_AUTH | upload pipeline | 401 / 403 from signed-URL issuer |
MEDIA_RATE_LIMITED | upload pipeline | 429 from signed-URL issuer |
MEDIA_NETWORK | upload pipeline | Network failure mid-upload |
MEDIA_UNKNOWN | upload pipeline | Unclassified upload failure |
SAVE_CONFLICT | save coordinator | Host onSave returned { ok: false, reason: "conflict" } |
SAVE_EMPTY | save coordinator | Editor canvas empty/invalid; nothing to persist |
SAVE_FAILED | save coordinator | Generic save failure (network, host reject, missing wiring) |
MediaUploadError.code retains its legacy lowercase value ("too_large", "bad_mime", …) on the instance for existing consumers; the SCREAMING_SNAKE_CASE codes above are the canonical PageHubError.code shape exposed by every class.
PageHubConfig shape, feature flags, the boot-time options that are NOT registries.setState / getState / mountUrlQueryStateBridge (not a registry but the same boot-time wiring pattern).