Extensibility

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:

  • Set once, before mount. Last call wins.
  • Single instance. No stacking — the latest registration replaces the previous one.
  • Reset to defaults by passing null (or calling reset*() where provided).
  • No PageHub-specific defaults unless explicitly noted; the SDK throws or no-ops when unset.

Import everything below from @pagehub/sdk.


Quick reference

RegistryWhen to callUse for
registerBlocksProviderBefore features.blocksPanel.enabled = trueCustom block library — REST, static JSON, in-memory fixture
registerComponentAllowlistBefore mountConstrained editor modes (email, kiosk) — opt-in component set
registerCatalogFilterBefore mountStrip presets/modifiers per component (e.g. drop backdrop-blur for email)
registerMediaUploadHandlerBefore any media UI rendersImage / file upload pipeline
registerMediaUploadAcceptBefore file pickers renderOverride the <input accept> MIME list
registerSubmissionHandlerBefore viewer / form-bearing pages mountForm submission persistence (POST /api/submissions, etc.)
registerClientDataFetcherBefore viewer / data-bound pages mountClient-side data sources (Stripe, Shopify, customer orders, etc.)
registerPresetsModule-load (usually self-registered by preset file)Add named variants for a component
registerModifiersModule-loadAdd modifier groups (size, intent, etc.) for a component
registerPropertiesBefore mountAdd custom inspector properties to the settings sidebar
registerSectionDefBefore mountAdd custom inspector sections

registerBlocksProvider

Replaces 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).
  • Pair with features.blocksPanel.enabled (in PageHubFeatures) to actually show the panel.

registerComponentAllowlist

Restricts 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:

  • Built-in components and host-supplied custom components are filtered against the same name set.
  • Passing an empty array clears the allowlist (same as resetComponentAllowlist()).
  • Last call wins (idempotent).

registerCatalogFilter

Per-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.

registerMediaUploadHandler

The 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:

  • Throws are surfaced to the user. Throw a MediaUploadError (from @pagehub/sdk errors module) for a typed code + message.
  • Images are pre-resized + AVIF→JPEG converted by the SDK before the handler runs. Non-image files pass through untouched.
  • destination is metadata the host owns — the SDK passes it back on delete so you can route to the right backend.

registerMediaUploadAccept

Sets 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:

  • Provider is a function (not a string) so plan changes can be re-read without re-registering.
  • When unset, the SDK falls back to DEFAULT_IMAGE_ACCEPT (the image-only Cloudflare Images list).
  • Replace flow uses per-item same-kind accept derived from MediaItem.metadata.contentType — this provider only governs new uploads.

registerSubmissionHandler

Where 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:

  • Called from both editor preview and the viewer / static-published pages.
  • 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.

registerClientDataFetcher

Bridges 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:

  • Called by the viewer / static-published pages when a 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.
  • Return null (not []) when you want the SDK to fall back to whatever SSR data it already has.
  • Pair with mountUrlQueryStateBridge() so URL params automatically flow into stateInputs.

registerPresets / registerModifiers

Catalog 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:

  • Idempotent — last call replaces the full list for that component name.
  • Each preset requires a label; each modifier requires name + label (validated at register time, throws on missing).
  • Viewer + static-renderer don't import catalog files, so presets/modifiers add zero bytes to those bundles.
  • Use registerCatalogFilter to subtract entries from a built-in catalog without replacing it.

registerProperties / registerSectionDef

Add 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.props in defineComponent. def.props populates a single component's own "Content" tab — see registration-host.md. registerProperties populates 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.
  • For custom input UIs, also register via registerCustomInput (separate API in chrome/toolbar/inspector/customInputs).
  • See sidebar-property-registry.md for the full property-input contract and built-in input types.

Errors

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

Hierarchy

  • PageHubError (base)
    • ConfigErrorPageHub.init() / <PageHubEditor> / viewer config.
    • ComponentDefinitionErrordefineComponent() schema + processor duplicate-name check.
    • CatalogRegistryErrorregisterPresets / registerModifiers / registerCatalogFilter.
    • BlocksProviderErrorregisterBlocksProvider.
    • MediaUploadError — upload pipeline (preserves the lowercase code field for backwards compat; the canonical SCREAMING_SNAKE_CASE code is MEDIA_*).
    • SaveConflictError / SaveEmptyError / SaveFailedError — save coordinator.

Codes by domain

CodeThrown byTrigger
CONFIG_CONTAINER_NOT_FOUNDPageHub.init(), PageHubViewer.init()config.container selector matched no DOM node
CONFIG_CALLBACKS_REQUIREDPageHub.init(), <PageHubEditor> standaloneconfig.callbacks (or callbacks prop) missing
CONFIG_ONSAVE_INVALIDPageHub.init()callbacks.onSave is not a function
CONFIG_ONLOAD_INVALIDPageHub.init()callbacks.onLoad is not a function
COMPONENT_NAME_REQUIREDdefineComponent()def.name missing or not a string
COMPONENT_NAME_INVALIDdefineComponent()def.name is not PascalCase
COMPONENT_NAME_BUILTIN_COLLISIONdefineComponent()def.name collides with a built-in component
COMPONENT_NAME_DUPLICATEprocessForEditor()Two defineComponent calls share the same name
COMPONENT_MISSING_COMPONENTdefineComponent()def.component missing or not a React component
COMPONENT_INVALID_TOHTMLdefineComponent()def.toHTML present but not a function
COMPONENT_PROP_MISSING_TYPEdefineComponent()A prop schema is missing type
COMPONENT_PROP_MISSING_LABELdefineComponent()A prop schema is missing label
COMPONENT_PROP_SLIDER_MISSING_BOUNDSdefineComponent()type: "slider" prop missing min / max
CATALOG_FILTER_INVALID_NAMEregisterCatalogFilter()componentName not a non-empty string
CATALOG_FILTER_INVALID_PREDICATEregisterCatalogFilter()predicate not a function
CATALOG_PRESETS_INVALID_NAMEregisterPresets()componentName not a non-empty string
CATALOG_PRESETS_NOT_ARRAYregisterPresets()presets is not an array
CATALOG_PRESET_INVALID_ENTRYregisterPresets()A preset entry is not an object
CATALOG_PRESET_MISSING_LABELregisterPresets()A preset entry is missing required string label
CATALOG_MODIFIERS_INVALID_NAMEregisterModifiers()componentName not a non-empty string
CATALOG_MODIFIERS_NOT_ARRAYregisterModifiers()modifiers is not an array
CATALOG_MODIFIER_INVALID_ENTRYregisterModifiers()A modifier entry is not an object
CATALOG_MODIFIER_MISSING_NAMEregisterModifiers()A modifier entry is missing required string name
CATALOG_MODIFIER_MISSING_LABELregisterModifiers()A modifier entry is missing required string label
BLOCKS_PROVIDER_INVALIDregisterBlocksProvider()Provider missing listCategories / listBlocks functions
STATIC_RENDER_PARSE_FAILEDrenderToHTML()json arg failed to parse as SerializedNodes
MEDIA_TOO_LARGEupload pipelineFile exceeds plan or CF Images cap (HTTP 413)
MEDIA_BAD_MIMEupload pipelineMIME not in allowlist / CF rejected (HTTP 415)
MEDIA_QUOTAupload pipelineStorage quota exhausted
MEDIA_CF_REJECTEDupload pipelineCloudflare direct_upload returned non-OK without a more specific status
MEDIA_AUTHupload pipeline401 / 403 from signed-URL issuer
MEDIA_RATE_LIMITEDupload pipeline429 from signed-URL issuer
MEDIA_NETWORKupload pipelineNetwork failure mid-upload
MEDIA_UNKNOWNupload pipelineUnclassified upload failure
SAVE_CONFLICTsave coordinatorHost onSave returned { ok: false, reason: "conflict" }
SAVE_EMPTYsave coordinatorEditor canvas empty/invalid; nothing to persist
SAVE_FAILEDsave coordinatorGeneric 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.


Related

  • host-configuration.mdPageHubConfig shape, feature flags, the boot-time options that are NOT registries.
  • host-constraints.md — constrained editor modes (email, kiosk) using these registries together.
  • registration-host.md — adding entirely new CraftJS components (different surface from extending existing ones).
  • state-system.mdsetState / getState / mountUrlQueryStateBridge (not a registry but the same boot-time wiring pattern).