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.
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.
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.
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.
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.
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).
Three flags on PageHubFeatures shape the constrained-editor surface. See the full flag table in host-configuration.md.
blocksPanelfeatures: { 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.
inspectorTabsfeatures: {
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.
cssAllowlistfeatures: {
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.
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",
],
},
},
});
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.
PageHubEditor component, different config payload.PageHubFeatures flag table → host-configuration.mdregister* registries in one reference → extensibility.md