Constrained editor modes

Email mode, CSS allowlist, catalog filters, blocks provider injection.

Boot @pagehub/sdk as a narrowed editor: fewer components, fewer inspector tabs, fewer style options, an empty Blocks library. Useful when the output medium can't support the full surface — email builders, kiosk apps, embedded form designers, etc.

Where host-configuration.md covers additive config (extra fonts, extra device presets), this doc covers subtractive config — strip things out.


The smallest constrained config

Email-mode in 15 lines. Restrict the toolbox to four components, hide the Blocks panel, allow only a handful of utility class prefixes:

import { PageHub, registerComponentAllowlist } from "@pagehub/sdk";

registerComponentAllowlist(["Text", "Container", "Button", "Image"]);

PageHub.init({
  features: {
    blocksPanel: { enabled: false },
    cssAllowlist: {
      classes: [/^(bg|text|p|m|gap|rounded|border|flex|grid|w|h)-/],
      properties: ["color", "background-color", "padding", "margin", "font-size"],
    },
  },
  callbacks: { onLoad, onSave },
});

That's a working email-safe editor. Everything below explains each knob and the niche ones.


The full set of constraint knobs

Five mechanisms, used in combination:

import {
  PageHub,
  registerComponentAllowlist,
  registerCatalogFilter,
  registerBlocksProvider,
} from "@pagehub/sdk";

// ─── Component catalog (toolbox-visible defs) ──────────────
registerComponentAllowlist(["Text", "Container", "Button", "Image"]);

// ─── Per-component preset/modifier filter ──────────────────
registerCatalogFilter("Button", (entry, kind) => {
  if (kind === "modifier") return !/animate-|backdrop-blur/.test(entry.classes ?? "");
  return true;
});

// ─── Custom Blocks source (or skip to use the default HTTP one) ──
registerBlocksProvider(myProvider);

PageHub.init({
  features: {
    blocksPanel:    { enabled: false },                // hide Blocks tab entirely
    inspectorTabs:  { Button: ["component", "design"] }, // trim inspector tabs per component
    cssAllowlist:   { classes: [...], properties: [...] }, // gate user-typed class/style
  },
  callbacks: { onLoad, onSave },
});

Each section below explains one mechanism.


Component allowlist

Restrict the toolbox to a named set of components. Inverse of features.restrictedComponents (which is a deny-list).

import { registerComponentAllowlist } from "@pagehub/sdk";

registerComponentAllowlist(["Text", "Container", "Button", "Image"]);

What it does: filters the toolbox allDefs list in editor.tsx. Built-ins and host-supplied custom components run through the same name filter. The CraftJS resolver still receives every built-in so previously-saved nodes hydrate correctly — only the visible defs are trimmed.

When you'd reach for it: you want a small fixed surface and starting from a deny-list of 30 names is the wrong direction.

Related helpers in define/componentAllowlist.ts: resetComponentAllowlist(), getComponentAllowlist(), isComponentAllowed(name). Pass [] to clear.

Heads up — server-side AI/MCP is separate. This registry is client-only. To restrict the components an AI agent or MCP call can emit, pass a per-request allowedTypes parameter to those surfaces. A module-level filter wouldn't work — those calls run server-side and multitenant. See utils/ai/schema-loader.ts and packages/mcp-core/src/component-registry.js.


Catalog filter — drop unsafe presets and modifiers

Per-component predicate over a component's preset and modifier lists. Applied at read time inside getPresets(name) / getModifiers(name) from define/catalogRegistry.ts.

import { registerCatalogFilter } from "@pagehub/sdk";

const EMAIL_UNSAFE = /\b(backdrop-blur|animate-|transform|filter|fixed|sticky)\b/;

registerCatalogFilter("Button", (entry, kind) => {
  if (kind === "modifier") {
    const m = entry as { classes?: string; expands?: string; name: string };
    return !EMAIL_UNSAFE.test(`${m.classes ?? ""} ${m.expands ?? ""} ${m.name}`);
  }
  return true;
});

The predicate receives the real ComponentPreset / ComponentModifier object — inspect whatever fields you care about (label, name, classes, expands, category). There's no synthetic tags field.

When you'd reach for it: the component itself is fine, but some of its variant chips emit CSS the output medium can't render (animations in email, transforms in print export, etc.).

Helpers: resetCatalogFilter(name?) clears a single component's filter (or all of them when called without args), getCatalogFilter(name) reads the current predicate.


Blocks provider — bring your own catalog

The Blocks panel (toolbox "Blocks" tab) renders categories + searchable block templates. The SDK consults a registered BlocksProvider. Hosts ship whatever backing store they want: REST API, static JSON file, IndexedDB, in-memory fixture.

import { registerBlocksProvider, type BlocksProvider } from "@pagehub/sdk";

const myProvider: BlocksProvider = {
  async listCategories() {
    return [
      { id: "hero", name: "Heroes", total: 4, subcategories: [], styles: [] },
      { id: "feature", name: "Features", total: 6, subcategories: [], styles: [] },
    ];
  },
  async listBlocks({ category, search, limit }) {
    let blocks = await fetchMyBlocks();
    if (category) blocks = blocks.filter(b => b.category === category);
    if (search) {
      const q = search.toLowerCase();
      blocks = blocks.filter(b => b.name.toLowerCase().includes(q));
    }
    return limit ? blocks.slice(0, limit) : blocks;
  },
};

registerBlocksProvider(myProvider);

Each BlockItem.structure must be a CraftJS-shaped node map (the same shape editor.exportJSON() produces) — either as a JSON object or a JSON string the SDK will parse on drop.

Default behavior when no provider is registered: the SDK uses defaultHttpBlocksProvider, which fetches /api/v1/components/categories and /api/v1/components against the host origin. That's PageHub's own setup — its app gets the legacy behavior with zero registration.

Static JSON catalog:

import blocksJson from "./blocks.json"; // BlockItem[]

registerBlocksProvider({
  async listCategories() {
    const byCat = new Map<string, number>();
    for (const b of blocksJson) byCat.set(b.category, (byCat.get(b.category) ?? 0) + 1);
    return [...byCat].map(([id, total]) => ({
      id, name: id, total, subcategories: [], styles: [],
    }));
  },
  async listBlocks({ category, search, limit = 100 }) {
    let out = blocksJson;
    if (category) out = out.filter(b => b.category === category);
    if (search) {
      const q = search.toLowerCase();
      out = out.filter(b => b.name.toLowerCase().includes(q));
    }
    return out.slice(0, limit);
  },
});

Pair the provider with features.blocksPanel.enabled = true to actually show the panel — it's false by default so standalone consumers don't get a broken panel pointing at endpoints they don't host.

Helpers: resetBlocksProvider(), getBlocksProvider() (returns the default when nothing is registered).


Feature flags

Three flags on PageHubFeatures shape the constrained-editor surface. See the full flag table in host-configuration.md.

blocksPanel

features: { blocksPanel: { enabled: false } }

Hides the Blocks tab in the toolbox (ToolboxTabs.tsx). URL state also collapses back to components when the tab disappears. Default is enabled: false — opt in for hosts that have a blocks provider (or use the default HTTP one).

Object-shaped (not a boolean) so future block-related settings can land here without another flag rename.

inspectorTabs

features: {
  inspectorTabs: {
    Button:    ["component", "design"],
    Text:      ["component", "design"],
    Container: ["component", "layout", "design"],
    Image:     ["component", "design"],
  },
}

Per-component allowlist of inspector tabs. Tab names: "component" | "layout" | "design" | "interactions" | "advanced". Omit a component and it keeps all tabs. Filters TABS in RegistrySettings.tsx.

cssAllowlist

features: {
  cssAllowlist: {
    classes: [/^(bg|text|p|m|gap|rounded|border|flex|grid|w|h)-/],
    properties: ["color", "background-color", "padding", "margin", "font-size"],
  },
}

Filters className tokens and inline style properties at the chrome input boundary. A class survives if at least one regex matches; an inline style declaration survives if its property name is in properties (kebab-case). Installed via core/cssAllowlist.ts, enforced in propSystem.changeProp.

Programmatic writes via CraftJS setProp — template / block hydration, hydration from onLoad, etc. — bypass the filter intentionally. The allowlist gates human input, not stored data.


Full email-mode boot

Everything wired together:

import {
  PageHub,
  registerComponentAllowlist,
  registerCatalogFilter,
  registerMediaUploadHandler,
} from "@pagehub/sdk";

registerComponentAllowlist(["Text", "Container", "Button", "Image"]);

const EMAIL_UNSAFE = /\b(backdrop-blur|animate-|transform|filter|fixed|sticky)\b/;
registerCatalogFilter("Button", (entry, kind) => {
  if (kind === "modifier") {
    const m = entry as { classes?: string; expands?: string; name: string };
    if (EMAIL_UNSAFE.test(`${m.classes ?? ""} ${m.expands ?? ""} ${m.name}`)) return false;
  }
  return true;
});

registerMediaUploadHandler(myEmailSafeUploader);

PageHub.init({
  callbacks: { onLoad, onSave },
  features: {
    sidebar: true,
    toolbar: true,
    blocksPanel: { enabled: false },
    aiGeneration: false,
    customCSS: false,
    multiPage: false,
    seoPanel: false,
    responsivePreview: false,
    inspectorTabs: {
      Button:    ["component", "design"],
      Text:      ["component", "design"],
      Container: ["component", "layout", "design"],
      Image:     ["component", "design"],
    },
    cssAllowlist: {
      classes: [/^(bg|text|p|m|gap|rounded|border|flex|grid|w|h)-/],
      properties: [
        "color", "background-color",
        "padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
        "margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
        "font-size", "font-weight", "line-height", "text-align",
      ],
    },
  },
});

Ordering rule

registerComponentAllowlist, registerCatalogFilter, registerBlocksProvider, and registerMediaUploadHandler are imperative registries — call them BEFORE mounting <PageHubEditor> / PageHub.init so the first render sees the constraints. After mount, changes to these registries do not trigger a re-render. Boot-time only.

features.* flags ARE reactive through the SDK config context — updates to blocksPanel, inspectorTabs, and cssAllowlist pick up when the resolved config changes.


What this doesn't do

  • Not a separate editor build. Same PageHubEditor component, different config payload.
  • Not email rendering. Only covers the editing surface. Producing email-safe HTML (inlined CSS, table layouts, etc.) is a separate export pipeline.
  • Not per-user / per-document overrides. Constraints are boot-time, host-app-wide. To vary by user, boot the editor with a different config when that user mounts it.

Where to go from here