npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pagepilot-visual-editor

v1.0.25

Published

Standalone visual editor — drop <PagePilotEditor> into any React app and get Style/Layout/Inspect/Layers panels, drag-and-drop blocks, undo/redo, dirty-tracked save, multi-section pages, and light/dark themes

Readme

pagepilot-visual-editor

A standalone, prop-driven React page-type visual editor. Drop <PagePilotEditor /> into your host application, pass the host record through props, and listen for onSaveChanges / onPublish callbacks — the editor handles the UI, block creation, multi-section management, and HTML rendering.

Two integration modes:

  1. Callback-only (default). The package is purely the editor: no backend calls, no host coupling. The host owns persistence, navigation, plan gating, theming overrides, and everything else.
  2. Service-account API (opt-in via the apiConfig prop). Point the editor at a pagepilot API instance and it takes over save / publish / template listing / section library / tenant-plan gating for you. Same auth pattern ahd-fe uses internally (Authorization: Bearer <token>), configured once at mount time. See Pagepilot API integration.

Install

npm install pagepilot-visual-editor
# or
pnpm add pagepilot-visual-editor
# or
yarn add pagepilot-visual-editor

Peer dependencies (your host should provide these):

| Peer | Range | Notes | |---|---|---| | react | ^18.0.0 \|\| ^19.0.0 | React 18 and 19 both supported | | react-dom | ^18.0.0 \|\| ^19.0.0 | Match your react version | | @mui/material | >=5 | v6 tested; v5 works | | @mui/icons-material | >=5 | | | @emotion/react | >=11 | Required by MUI | | @emotion/styled | >=11 | Required by MUI |

Also required at runtime (not currently listed as a peer, but the built bundle keeps them external — install these alongside the peers above):

npm install styled-components zustand

If you want to render exported types like TReaderBlockProps or use the ReaderBlockSchema, add zod too:

npm install zod

Quick start

import { PagePilotEditor } from "pagepilot-visual-editor";

export default function PageEditorScreen() {
  const [record, setRecord] = useState(initialRecord);

  // The container MUST have an explicit height (100vh, a fixed pixel value,
  // or a flex parent that gives it space). The editor fills its parent 100%;
  // if the parent has 0 height, so does the editor. `width: 100%` is
  // inherited from the ScopedCssBaseline root inside the component.
  return (
    <div style={{ width: "100%", height: "100vh" }}>
      <PagePilotEditor
        record={record}
        recordTitle={record.title}
        recordStatus={record.status}
        onUpdateRecord={setRecord}
        onSaveChanges={async ({ record, html }) => {
          await api.savePage(record.id, { record, html });
        }}
        onPublish={async ({ record, html }) => {
          await api.publishPage(record.id, { record, html });
        }}
        onBack={() => navigate(-1)}
      />
    </div>
  );
}

The editor renders its own MUI ThemeProvider + ScopedCssBaseline, so it works inside hosts that already use MUI — you don't need to import our CSS separately. If your host sets a non-default html { font-size }, our ScopedCssBaseline re-anchors REM sizing at 14 px inside the editor so the built-in layout doesn't scale unexpectedly.


Anatomy

┌──────────────────────────────────────────────────────────────────────────┐
│ ← Back  My Page  ● Draft  [Section ▾]   ↶ ↷ ⊟  Save  ⋮  ⤢  ▶  Publish ▾  │  Topbar
├──────┬───────────────────┬─────────────────────────────────────────────┤
│Style │ Appearance        │                                             │
│Layout│  Color  ▢ ...     │     (canvas — block-based editor)           │
│Inspct│  Font   ▾         │                                             │
│Layers│  Radius PX        │                                             │
│Vars* │ Layout            │                                             │
│SEO   │  Width  100 %     │                                             │
│PgPlt │                   │                                             │
└──────┴───────────────────┴─────────────────────────────────────────────┘
                                              * only when template has vars
  • Left rail — up to 7 panels: Style, Layout, Inspect, Layers, Variables, SEO, Pagepilot (each can be toggled off via the panels prop). Variables auto-hides unless a template with fields is applied or a repeatable container is selected. Pagepilot currently renders a "Coming soon" placeholder — the full AI page generator lands in a future release.
  • Topbar — every control is individually toggleable via the topbar prop. Includes zoom out / zoom-% / zoom in (also driven by Ctrl/Cmd + wheel) / undo / redo / deselect block (auto-hidden when nothing is selected) / save / auto save (opt-in) / desktop-tablet-mobile / theme / more menu / fullscreen / preview / publish.
  • Canvas — the block-based page editor. Click the empty-state "Add first section" or the ⋮ → Add Section. A minimap overview (togglable from the 3-dot menu) docks to the bottom-right and lets you click / drag to scroll long pages. Unsaved edits show the browser's native "Leave site?" prompt on tab close or reload.
  • Block TuneMenu — appears above any selected block: drag handle (native reorder) / move up / move down / delete / open in Layers / edit variables (template-bound blocks only) / more menu (Save as template / Duplicate / Copy / Paste / Replace / Hide).
  • 3-dot menu — hidden by default; enable via topbar.moreMenu. Contains Add Section and Show / Hide Minimap.
  • Layout panel — the Blocks palette is grouped into six categories (Text/Content, Media, Layout/Structure, Actions/Controls, Embeds/Integrations, Support/Utility). When apiConfig is passed, two additional tabs appear: Templates (global page templates) and Saved (per-tenant templates), plus a Section Library tile that opens a full gallery of section templates.

<PagePilotEditor> props — full reference

1. Host data

| Prop | Type | Default | Behavior | |---|---|---|---| | record | PageRecord | undefined | The full page record. Mutations are echoed back through onUpdateRecord. | | contentMetadata | PageRecord["contentMetadata"] | undefined | Top-level override for record.contentMetadata. When supplied, wins over record.contentMetadata. | | sections | PageRecord["sections"] | undefined | Top-level override for record.sections. Section array for multi-section pages. | | activeStepIndex | number | 0 | Index of the currently-active section in the canvas. The editor also tracks this internally via the section dropdown. | | behaviour | Record<string, any> | undefined | Passthrough only — the editor does not consume this. Preserved in the save/publish payload if non-empty so host-owned config survives a round-trip. | | steps | PageRecord["steps"] | undefined | Passthrough only — legacy shape from the original tour/tooltip editor. The page editor does not read or render steps; it's preserved in the save payload when non-empty. | | apiConfig | PagepilotApiConfig | undefined | Opt-in pagepilot API integration. When passed, save/publish PUT the record through the API, Layout gets Templates / Saved tabs, Add-Block menu gets the Section Library tile, Publish opens a rich dialog, Variables panel gates on tenant plan. See Pagepilot API integration. |

The editor reads record.contentMetadata.document (or record.sections[activeStepIndex].contentMetadata.document for multi-section pages) and seeds the canvas on first mount. After that the editor owns the document state; the host receives mutations via onUpdateRecord.

PageRecord shape

interface PageRecord {
  id?: string;
  type?: string;                       // keep "page" for the page editor
  title?: string;                      // displayed when `recordTitle` is not set
  name?: string;                       // fallback for title
  status?: string;                     // displayed as status pill ("draft" | "live" | ...)
  contentMetadata?: {
    document?: any;                    // the editor's source-of-truth document tree
    html?: string;                     // set by the editor on save
    [key: string]: any;
  };
  behaviour?: Record<string, any>;
  steps?: Array<{                      // for tour/tooltip flows
    contentMetadata?: { document?: any; [key: string]: any };
    behaviour?: Record<string, any>;
    [key: string]: any;
  }>;
  sections?: PageSection[];            // for multi-section pages
  [key: string]: any;                  // free-form passthrough
}

interface PageSection {
  _id?: string;
  title?: string;
  identifier?: string;
  sectionType?: "internal" | "external";
  content?: string;                    // per-section rendered HTML (set by editor on save)
  contentMetadata?: {
    document?: any;                    // per-section editor document
    [key: string]: any;
  };
  [key: string]: any;
}

2. UI configuration

| Prop | Type | Default | Behavior | |---|---|---|---| | panels | EditorPanelToggles | all 4 visible | Toggle the four left-rail panels independently. | | title | string | "Back" | Label rendered on the back button. | | recordTitle | string | record.title ?? record.name | Text shown after the back button. Hidden when empty. | | recordStatus | string | record.status | Pill shown after the title. Built-in color coding for draft (orange), live / published (green), scheduled (indigo). Any other string gets a neutral gray pill. | | mode | "light" | "dark" | "dark" | Selects the built-in theme. Ignored when theme is set. | | theme | Theme (MUI) | undefined | Full MUI theme override. Wins over mode. | | translations | Record<string, string> | {} | Merged on top of the built-in English dictionary. Missing keys fall back to defaults; missing default keys fall back to humanizing the last segment of the key. | | topbar | TopbarToggles | see below | Per-control visibility for the topbar. | | blocks | BlockToggles | all true | Per-tile visibility for the Add-Block menu. | | autoSaveEnabled | boolean | undefined | Controlled value for the Auto Save switch. When undefined the switch is uncontrolled and its state is persisted to localStorage under pagepilot.editor.autoSave.v1. When set, the host owns the checked state and must update it via onAutoSaveChange. The switch itself is only rendered when topbar.autoSave === true. | | autoSaveIntervalMs | number | 60000 | Autosave interval in milliseconds. Matches ahd-fe's 60-second cadence. Set to 0 to disable the interval while still showing the switch (useful for tests). | | headerExtra | ReactNode | undefined | Rendered immediately below the topbar — use for custom banners/announcement bars. | | children | ReactNode | undefined | Rendered after the editor body. |

EditorPanelToggles

interface EditorPanelToggles {
  style?: boolean;     // default true
  layout?: boolean;    // default true
  inspect?: boolean;   // default true
  layers?: boolean;    // default true
  variables?: boolean; // default true — auto-hides when no template forms
                       //                and no repeatable container selected
  seo?: boolean;       // default true
  pagepilot?: boolean; // default true — Pagepilot AI tab (currently
                       //                renders a "Coming soon" placeholder)
}

TopbarToggles

interface TopbarToggles {
  back?: boolean;              // default true
  title?: boolean;             // default true — record title
  status?: boolean;            // default true — status pill
  sectionsDropdown?: boolean;  // default true — still requires sections.length > 1
  undo?: boolean;              // default true
  redo?: boolean;              // default true
  unselect?: boolean;          // default true — Deselect Block button
                               //                (auto-hides when nothing selected)
  save?: boolean;              // default true
  autoSave?: boolean;          // default FALSE — Auto Save switch next to Save Changes
  screenSize?: boolean;        // default true — Desktop/Tablet/Mobile icon strip
                               //                (also drives which per-device slice
                               //                 the sidebar is editing)
  themeToggle?: boolean;       // default true — sun/moon button flipping light/dark
  fullscreen?: boolean;        // default true
  preview?: boolean;           // default true
  moreMenu?: boolean;          // default FALSE
  publish?: boolean;           // default FALSE
}

Defaults note: autoSave, moreMenu, and publish are off by default — they only appear when explicitly enabled. Everything else is on by default.

BlockToggles

Per-tile visibility for the Add-Block menu. Each key defaults to true.

type BlockKey =
  | "heading" | "list" | "text" | "button" | "image" | "logo" | "video"
  | "avatar" | "divider" | "spacer" | "html"
  | "columns" | "flexColumns" | "grid"
  | "buttonGroup" | "container"
  | "stepper" | "accordion" | "pricingAccordion"
  | "tabs" | "carousel" | "rating" | "marquee"
  | "iframe" | "skipToMain" | "accessibilityTools"
  | "timer" | "pagination" | "table"
  | "demoPicker" | "menuPicker" | "formsPicker"
  | "appBannerPicker" | "faqPicker";

type BlockToggles = Partial<Record<BlockKey, boolean>>;
<PagePilotEditor
  blocks={{
    iframe: false,         // hide the iFrame tile
    accessibilityTools: false,
    marquee: false,
  }}
/>

3. Callbacks

| Prop | Signature | When it fires | |---|---|---| | onUpdateRecord | (record: PageRecord) => void | Whenever the editor mutates the document, adds/removes/reorders sections, etc. The host owns the record state and should call setState/equivalent here. | | onChange | (document: any) => void | Lower-level: fires on every document tree mutation. Use when you only need the canvas document, not the full record. | | onSaveChanges | (payload: SaveChangesPayload) => Promise<boolean \| void> \| boolean \| void | Fires when Save Changes is clicked. Receives the full payload (record + document + rendered HTML). Return true (or void) for success, false to indicate the save failed. | | onPublish | (payload: SaveChangesPayload) => Promise<void> \| void | Fires when the Publish button is clicked. Same payload as Save. | | onPublishMenu | (anchorEl: HTMLElement, payload: SaveChangesPayload) => void | Fires when the small ▾ arrow next to Publish is clicked. The host can open any custom popover/menu/dialog anchored to anchorEl. Omit to disable the arrow. | | renderPublish | ({ payload, onPublish }) => ReactNode | Fully replaces the Publish split-button group with the host's component. When provided, onPublish / onPublishMenu are ignored — host owns the slot. | | onBack | () => void | Fires when the ← Back button is clicked. | | onTogglePreview | (html: string, doc: any) => void | Optional preview hook. If provided, the host owns the preview UI (typically opening its own dialog with html). If omitted, the editor opens its built-in in-page preview dialog with desktop/tablet/mobile size toggles. | | onBackgroundChange | (image: string) => void | Fires when the page-level background image changes. | | onNavigateBranding | () => void | Fires from the Brand panel (currently stripped from the standalone build — kept for forward-compat). | | onAddSection | () => void | Custom Add-Section handler. If provided, the editor delegates entirely to the host — no built-in dialog. If omitted, clicking ⋮ → Add Section opens the built-in dialog. | | onAutoSaveChange | (enabled: boolean) => void | Fires when the user flips the Auto Save switch. Always fires (both controlled and uncontrolled use) so hosts can mirror the flag to their own settings store. Only relevant when topbar.autoSave === true. | | onApiError | (error: Error, action: "save" \| "publish") => void | Fires when a save/publish through apiConfig fails (network error, non-2xx response, invalid credentials, missing page id, etc.). Without this callback, failures only surface via console.error — the Save button stays "dirty" with no user-visible feedback. Use it to show a toast, refresh the auth token, redirect to a login screen, etc. Only relevant when apiConfig is set. | | onUpgrade | () => void | Fires when the user clicks the Upgrade to unlock CTA on the Variables-panel paywall. Route it to your billing / upgrade UI and the host owns the whole flow. Without this callback the built-in <UpgradeDialog> opens instead — a lightweight fallback whose "Upgrade Plan" button links to the pagepilot billing URL in a new tab. Only relevant when apiConfig is set. |

SaveChangesPayload

Every save/publish callback receives this shape:

interface SaveChangesPayload {
  record: PageRecord;       // updated record with sections + html flushed in
  document?: any;           // present in single-doc mode; OMITTED in multi-
                            // section mode (there's no single page-level doc —
                            // read record.sections[i].contentMetadata.document
                            // per section instead)
  html: string;             // rendered HTML — for multi-section pages, every
                            // section's fragment is concatenated inside one
                            // <html> shell with viewport meta + base font
}

Multi-section deduplication. When record.sections[] is present, the payload's record is stripped down — the record-level contentMetadata and any empty behaviour / steps are removed because sections are the source of truth. Non-empty host-provided behaviour or steps are preserved so real host-owned config data isn't dropped.

// multi-section save payload — nothing duplicated
{
  "record": {
    "id": "demo-1", "type": "page", "title": "My Page", "status": "draft",
    "sections": [
      {
        "title": "Section 1",
        "content": "<div ...>…</div>",
        "contentMetadata": { "document": { /* per-section doc */ } }
      }
    ]
    // no contentMetadata, no empty behaviour, no empty steps
  },
  "html": "<!doctype html>…combined…"
  // no outer document
}

Single-doc mode (no sections) is unchanged — the payload still carries document + record.contentMetadata.document + record.contentMetadata.html.


Pagepilot API integration (optional)

Passing apiConfig to <PagePilotEditor> turns the editor into a full page-management surface. Under the hood it uses the exact auth + endpoint shapes that ahd-fe uses internally (Authorization: Bearer <serviceAccountToken>), so pages saved from the standalone editor round-trip identically to pages saved from ahd-fe.

What you get

| Feature | Where it appears | |---|---| | Save Changes → POST /tenant/:tenantId/upsert-page-item | Topbar Save button + apiClient.updatePage fallback | | Publish → same endpoint with status: "live" + schedule | Topbar Publish opens a rich PagePublishDialog | | Templates tab (global page templates) | Layout panel > Templates | | Saved tab (per-tenant my-templates) | Layout panel > Saved | | Section Library gallery | Add-Block menu → Section Library tile | | Template preview dialog (Use / Use Styles / Add Section) | Clicking any template card | | Menu Links tab (add page to tenant menu) | Publish dialog | | Variables panel with paywall gated on tenant plan | Left rail → Variables (auto-shown when template has fields) | | getTenant() for plan detection | Runs once on mount when apiConfig is set |

Getting your credentials

Before you can pass apiConfig, you need two things from your pagepilot admin console: a tenantId and an authToken. Both come from screens inside pagepilot itself. Skim the subsections in order — steps 1 and 2 are the actual credentials, the rest is production plumbing.

Prerequisites

  • A pagepilot account on pagepilot.fabbuilder.com with access to at least one workspace (called a "tenant" everywhere in the API). The Free plan is enough to get save + publish working; some features gate behind Pro (see Variables panel + tenant plan gating).
  • Owner or Admin role in that workspace. Members with Editor / Viewer roles can't mint service accounts — the "Create Service Account" link is hidden.
  • The workspace must already have at least one page record created (via pagepilot.fabbuilder.com or a direct POST /tenant/:tenantId/page). The editor's record.id must be a real backend id — see Setup.

Step 1 — Get your tenantId

(Workspaces → Workspace Id)

Open pagepilot.fabbuilder.com/tenant. The page lists every workspace you belong to; the one shown at the top with the green Active badge is the workspace whose pages the editor will read + write. The Workspace Id printed under the workspace name IS your tenantId — copy it.

Workspaces page — the Workspace Id field is your tenantId

Notes:

  • The id is a 24-char MongoDB ObjectId (e.g. 69e34f656c1eca798ec815bd). If yours has dashes or spaces, you've grabbed something else.
  • If you belong to multiple workspaces, each has its own tenantId — pages don't cross tenant boundaries. Pick the workspace that owns the pages you want to edit.
  • Switching workspaces? Change the Active workspace in pagepilot first (via the top-left workspace picker), then re-copy the id — or just remember that tenantId is a per-workspace value.

Tip: use the copy icon next to the id — the workspace name in the header is a link and can trigger a navigation if you double-click into the text.

Step 2 — Mint an authToken

(Settings → Teams → Service Account)

The editor authenticates to the pagepilot API with a service-account bearer token (a long-lived JWT scoped to the workspace). To create one:

  1. Go to Settings → Teams in pagepilot (pagepilot.fabbuilder.com/settings/invite-members).
  2. Toggle Show Service Accounts on (top-right, next to "Invite Member"). This reveals the Create Service Account link — service accounts are hidden by default so the Teams tab stays clean for admins who don't use them.
  3. Click Create Service Account.

Settings → Teams tab with "Create Service Account" highlighted

In the modal that opens, fill in three fields:

| Field | What to enter | Notes | |---|---|---| | Service Account ID | A descriptive name, e.g. visual-editor-prod. | Free-form. Shows up in the Teams table so you can revoke it later. | | Access | Pick the lowest role that satisfies your use case. | See the access-level table below. Admin is a safe default for editing + publishing. | | Expiration | Any horizon from 7 days to 1 year. | The editor does not refresh the token itself — pick a window that matches your rotation policy. 30 days is a reasonable default; use a shorter window for dev / demo tokens. |

Access levels (which one to pick):

| Role | Can read pages | Can save (draft) | Can publish (status: "live") | Can list / create templates | |---|---|---|---|---| | Viewer | ✅ | ❌ | ❌ | Read-only | | Editor | ✅ | ✅ | ❌ | Read + save-as-template | | Admin | ✅ | ✅ | ✅ | Full | | Owner | ✅ | ✅ | ✅ | Full + billing |

For most editor integrations, pick Admin — the editor needs both save + publish and reads Templates / Saved / global-template endpoints. Only drop to Editor if the embedded editor is a "draft-only" surface where publishing happens elsewhere.

Service Account creation modal — click Create to mint the token

Click Create. Pagepilot shows the generated token exactly once in the row that appears in the Teams table — copy it into your secret store immediately. If you miss it, revoke the account and mint a new one; there's no way to re-view the token later.

This is the same value ahd-fe stores in VITE_PAGEPILOT_SERVICE_AUTH_TOKEN, so if you're migrating from an ahd-fe integration you already have one.

Storing credentials safely

Anyone with the token can read + write every page under your tenant, so treat it like a database password.

Never commit it to the repo. Read it from an env var at build time (Vite / Next.js) or fetch it from your own backend at runtime (recommended for browser apps that don't want a long-lived admin token shipped to the client).

Vite — put the token in .env.local (already gitignored by the Vite starter):

# .env.local
VITE_PAGEPILOT_BASE_URL=https://pagepilot.fabbuilder.com/api
VITE_PAGEPILOT_TENANT_ID=6655bc2b30a6760d8f897581
VITE_PAGEPILOT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
<PagePilotEditor
  apiConfig={{
    baseUrl: import.meta.env.VITE_PAGEPILOT_BASE_URL,
    tenantId: import.meta.env.VITE_PAGEPILOT_TENANT_ID,
    authToken: import.meta.env.VITE_PAGEPILOT_TOKEN,
  }}
/>

Next.js — use a server component + prop drilling (or a server action) so the token never enters the client bundle:

// app/editor/[id]/page.tsx  — server component
import { PagePilotEditorClient } from "./PagePilotEditorClient";

export default async function EditorPage({ params }: { params: { id: string } }) {
  const token = process.env.PAGEPILOT_TOKEN!; // server-only, no NEXT_PUBLIC_
  return (
    <PagePilotEditorClient
      pageId={params.id}
      baseUrl={process.env.PAGEPILOT_BASE_URL!}
      tenantId={process.env.PAGEPILOT_TENANT_ID!}
      authToken={token}
    />
  );
}
// app/editor/[id]/PagePilotEditorClient.tsx  — "use client"
"use client";
import { PagePilotEditor } from "pagepilot-visual-editor";
export function PagePilotEditorClient(props: { pageId: string; baseUrl: string; tenantId: string; authToken: string }) {
  return (
    <PagePilotEditor
      record={{ id: props.pageId, type: "page" }}
      apiConfig={{ baseUrl: props.baseUrl, tenantId: props.tenantId, authToken: props.authToken }}
    />
  );
}

Backend proxy (safest for multi-user apps). Instead of shipping a service-account token to the browser, put your own backend in front of pagepilot: the browser authenticates to your backend with the user's session cookie, and your backend adds the pagepilot bearer server-side. Point apiConfig.baseUrl at your proxy (https://myapp.com/pagepilot-proxy), keep authToken empty (or a proxy-side session id — the editor sends whatever you give it verbatim), and enforce per-user auth on the proxy. This is how ahd-fe handles pagepilot in production.

Rotating a token

The editor doesn't refresh tokens itself — when yours expires, every save + publish call returns 401 Unauthorized and lands in onApiError. The rotation flow is manual:

  1. Mint a new service account (or edit the existing one's expiration) in pagepilot Settings → Teams.
  2. Update the token in your env var / secret store.
  3. Redeploy (or trigger a config refresh — the editor re-reads apiConfig on prop change, so hot-swapping in-place works if you can update the prop).
  4. Revoke the old service account in the Teams table so a leaked copy stops working.

Handle expiration gracefully in onApiError:

<PagePilotEditor
  apiConfig={apiConfig}
  onApiError={(err, action) => {
    if (err.message.includes("401") || err.message.includes("Unauthorized")) {
      // Token expired or was revoked. Redirect to your re-auth flow.
      window.location.href = "/reauth-pagepilot?returnTo=" + encodeURIComponent(window.location.pathname);
      return;
    }
    toast.error(`${action} failed: ${err.message}`);
  }}
/>

Troubleshooting credentials

| Symptom | Likely cause | Fix | |---|---|---| | 401 Unauthorized on every save | Token expired, revoked, or copied incompletely. | Mint a fresh token; make sure you copied the whole eyJ… string with no trailing whitespace. | | 403 Forbidden on Publish only | Service account role is Editor (or lower). | Recreate as Admin, or move publish to a separate token / server route. | | 404 Not Found on save, but the page exists in pagepilot | record.id doesn't match a page in this tenant, OR tenantId points at the wrong workspace. | Double-check the Active workspace on the Workspaces page. record.id must be a MongoDB ObjectId that already exists — the editor doesn't create pages, it only updates them. | | CORS error in the browser console (Access-Control-Allow-Origin) | Origin isn't in pagepilot's allowed origins for this tenant. | Add your dev / prod origin to the tenant's allowed origins in pagepilot Configurations, OR proxy the request through your own backend. | | Save appears silent — no error, no persisted change | apiConfig is set but record.id === "doc-1" (the demo seed). | Pass a real backend id via record.id before the API path fires. Demo ids are intentionally skipped. | | onApiError never fires but Save is stuck "dirty" | onApiError isn't set and errors are going to console.error only. | Wire onApiError — see the snippet in Rotating a token. |

Verifying your setup

Before wiring the credentials into the editor, sanity-check them with a plain curl (or fetch) from a terminal:

curl -i \
  -H "Authorization: Bearer $VITE_PAGEPILOT_TOKEN" \
  "https://pagepilot.fabbuilder.com/api/tenant/$VITE_PAGEPILOT_TENANT_ID/page?limit=1&offset=0"

Expected: HTTP/1.1 200 OK and a JSON body with a rows array. If you see:

  • 401 — token is wrong or expired. Re-mint.
  • 403 — token is valid but role too low for reads. Mint an Admin token.
  • 404 — the tenantId doesn't exist. Re-copy from the Workspaces page.
  • 200 with rows: [] — credentials are fine; the tenant just has no pages yet. Create one in pagepilot, then use its id in record.id.

Once the curl returns 200, drop the same three values into apiConfig and the editor's Save + Publish will hit the same endpoint.

Setup

import { PagePilotEditor } from "pagepilot-visual-editor";

<PagePilotEditor
  record={page}
  apiConfig={{
    baseUrl: "https://pagepilot.fabbuilder.com/api",  // no trailing slash, no "/tenant"
    tenantId: "6655bc2b30a6760d8f897581",             // from step 1 above
    authToken: "eyJhbGciOi…",                         // from step 2 above
  }}
  onApiError={(err, action) => toast.error(`${action}: ${err.message}`)}
  onUpgrade={() => openBillingModal()}
  // onSaveChanges / onPublish still fire AFTER a successful API write
  // if you also want to react (e.g. clear a dirty flag, refresh a listing).
  onSaveChanges={async ({ record }) => { revalidatePageList(); }}
/>

Requirements for the API path to actually fire:

  • record.id must be a real backend id (a MongoDB ObjectId in the tenant). The built-in seed id "doc-1" is intentionally skipped so demo records don't POST to a non-existent page.
  • The authToken must be a valid service-account bearer for the given tenantId — see Getting your credentials above.

PagepilotApiConfig shape

interface PagepilotApiConfig {
  /** API root without a trailing slash, e.g. "https://pagepilot.fabbuilder.com/api". */
  baseUrl: string;
  /** Tenant id used in `/tenant/${tenantId}/...` paths. */
  tenantId: string;
  /** Pre-issued service-account bearer. Sent as `Authorization: Bearer <token>`. */
  authToken: string;
}

Save + Publish request shape

The editor batches the render + payload build for you. When the user clicks Save Changes or Publish, this exact request goes out:

POST https://pagepilot.fabbuilder.com/api/tenant/${tenantId}/upsert-page-item
Content-Type: application/json
Authorization: Bearer ${authToken}

{
  "data": {
    ...record,                    // full record — id, name, slug, tags, groups, tenant, …
    "pageId": "6a43bc20…",        // === record.id
    "status": "live" | "draft",   // "draft" for Save Changes, "live" for Publish
    "sections": [                 // rendered sections (source of truth for multi-section pages)
      {
        "title": "Section 1",
        "sectionType": "internal",
        "identifier": "section-1784032010220",
        "content": "<div …>…rendered HTML…</div>",
        "contentMetadata": {
          "document": { "root": { "type": "EmailLayout", … }, "block-…": {…} }
        }
      }
    ],
    "startDate": "…",             // optional — only when Publish schedule is set
    "endDate": "…"                // optional — only when Publish schedule is set
  }
}

Same shape ahd-fe writes. The rendered sections[].content HTML is what the pagepilot public page-serving layer reads.

Failure handling. Any non-2xx response throws inside the editor. Without onApiError, the error is logged to console.error and the Save button stays visually "dirty" (no state change). Passing onApiError lets you surface it to the user:

<PagePilotEditor
  apiConfig={apiConfig}
  onApiError={(err, action) => {
    console.error(`[pagepilot] ${action} failed`, err);
    toast.error(err.message);
  }}
/>

The Publish dialog

When apiConfig is set, clicking Publish opens a full dialog instead of firing onPublish immediately. The dialog mirrors ahd-fe's PagePublishDialog:

  • Basic Info tab: page name, slug, publish date, "All Time Live" toggle, end date (when off).
  • Menu Links tab: pick a tenant menu (fetched via apiClient.listMenus) and append this page as a new item to that menu's configuration.
  • Scripts & Styles tab: paste a stylesheet snippet that's inlined into the published HTML, and flip per-page injection toggles for the built-in tabs scroll-sync and carousel scripts. Both toggles default on inside renderToStaticMarkup; setting them here lets a page opt out.
  • Save Draft and Publish buttons at the bottom. Both use the same upsert-page-item endpoint above; the CTA differs by the status field.

If you'd rather keep the direct one-click publish (fires onPublish without a dialog), set apiConfig to undefined — the direct callback flow still works.

Templates + Saved + Section Library

  • Templates tab (Layout > Templates): fetches GET /global-template?filter[type]=page&limit=10&offset=0. Click a card → preview dialog with three actions:
    • Use this Template — replaces the canvas with the template's document.
    • Use this Styles — overlays only the template's root visual styles (colors, fonts, radius, padding) onto your current document; blocks stay put.
    • Add this section — appends the template's blocks to the current document (id-remapped so a second apply doesn't collide with the first).
  • Saved tab (Layout > Saved): same UI, fetches GET /tenant/${tenantId}/mytemplate?filter[type]=page&limit=10&offset=0. Includes a delete affordance on hover.
  • Section Library modal: opened from the "Section Library" tile in the Add-Block menu. Fetches GET /global-template?filter[type]=block&limit=100&offset=0, groups by category (hero, features, testimonial, pricing, cta, faq, about, team, contact, footer, …), and inserts the picked block as a new section.

Every apply route also populates the Variables panel from the template's declared formFields, so template tokens ({{name}}, {{price}}, {{FAQs}}, …) are ready to fill in immediately.

Paywall. Templates with a supportedPlans: ["proCombo"] (or higher) show an "Upgrade to unlock" prompt in the preview dialog when the tenant plan is below that tier. The onUpgrade callback routes the CTA to your billing UI.

Variables panel + tenant plan gating

The Variables tab surfaces template tokens for filling at edit time. Visibility is controlled by:

  1. The template must declare fields. The tab auto-hides on documents with no template forms and no repeatable container selected.
  2. The tenant plan must be Pro or above (or the template must be unrestricted). The gate is checked once via apiClient.getTenant() on mount:
    • plan === "proCombo" | "enterprise" → real field editors render.
    • Anything else (including "free", "starterCombo", missing plan, failed fetch) → paywall renders.
  3. The paywall CTA ("Upgrade to unlock") fires onUpgrade if the host provided it; otherwise it opens the built-in <UpgradeDialog>, which links to the pagepilot billing URL in a new tab.

The whole gating logic mirrors ahd-fe's useTenantPlan hook and can be safely disabled by passing panels={{ variables: false }} — the whole tab disappears from the rail.

Direct API client access

The PagepilotApiClient class the editor uses internally is also exported so hosts can hit the same endpoints outside the editor tree — e.g. to create a page before mounting:

import { PagepilotApiClient } from "pagepilot-visual-editor";

const client = new PagepilotApiClient({
  baseUrl: "https://pagepilot.fabbuilder.com/api",
  tenantId: "6655bc2b30a6760d8f897581",
  authToken: process.env.PAGEPILOT_SERVICE_TOKEN!,
});

const created = await client.createPage({
  name: "New Onboarding Page",
  slug: "onboarding",
  groups: [],
});

// Then mount the editor pointing at the new page.
<PagePilotEditor record={{ id: created.id, name: created.name }} apiConfig={...} />

Available methods:

| Method | Endpoint | |---|---| | createPage(data) | POST /tenant/:tenantId/page | | updatePage(id, data) | PUT /tenant/:tenantId/page/:id | | quickSavePage(id, data) | PATCH /tenant/:tenantId/page/:id | | upsertPage(data) | POST /tenant/:tenantId/upsert-page-item (used by save + publish) | | findPage(id) | GET /tenant/:tenantId/page/:id | | getTenant() | GET /tenant/:tenantId (used by plan gate) | | listGlobalTemplates(params) | GET /global-template | | findGlobalTemplate(id) | GET /global-template/:id | | destroyGlobalTemplates(ids) | DELETE /global-template | | listTenantTemplates(params) | GET /tenant/:tenantId/template | | findTenantTemplate(id) | GET /tenant/:tenantId/template/:id | | listMyTemplates(params) | GET /tenant/:tenantId/mytemplate | | createTemplate(data) | POST /tenant/:tenantId/template | | destroyTenantTemplates(ids) | DELETE /tenant/:tenantId/template | | listMenus(params) | GET /tenant/:tenantId/menu | | updateMenu(id, data) | PUT /tenant/:tenantId/menu/:id |

All methods send Content-Type: application/json + the bearer token automatically.


Behavior reference

Topbar buttons

| Button | Behavior | |---|---| | ← Back | Calls onBack. The host typically navigates away. | | Page title + status pill | Display-only. Sourced from recordTitle / recordStatus (or record.title / record.name / record.status). | | Section dropdown | Only appears when record.sections.length > 1. Lets the user pick / edit / delete sections. Switching a section flushes the current canvas edits into the active section, then loads the target section's document onto the canvas. | | Zoom controls (− / % / +) | Zoom out, current zoom level (click to reset to 100%), zoom in. Range is 25%–100%; buttons disable at the endpoints. Users can also hold Ctrl / Cmd and scroll on the canvas to zoom cursor-anchored. Hidden together with Undo/Redo via topbar={{ undo: false }} (they share the toggle since they sit in the same group). | | Undo / Redo | Hooked to the editor's internal history stack. Buttons disable when there's nothing to undo/redo. A settle-window guard on mount prevents block-level useEffects (auto-seed, positioning re-measure, etc.) from preloading spurious entries — Undo starts disabled on every fresh load until the user makes a real edit. | | Deselect Block ⊟ | Only appears when a block is selected on the canvas. Clears selectedBlockId so the block's TuneMenu / handles collapse and the Inspect panel unfocuses. Same behavior as ahd-fe's TabUnselectedOutlined button. Hidden entirely via topbar={{ unselect: false }}. | | Save Changes | Dirty-tracked. Disabled with tooltip "No unsaved changes" until the user makes their first edit; enables on every subsequent edit; disables again after onSaveChanges resolves (unless the handler returns false). Builds the payload (renders document → HTML, flushes sections) and calls onSaveChanges. When apiConfig is set, the editor first persists via POST /upsert-page-item, then fires onSaveChanges — errors surface via onApiError. | | Auto Save | Opt-in — hidden unless topbar={{ autoSave: true }}. Bordered label + toggle switch rendered immediately left of Save Changes. While on, the editor re-runs the same handler Save Changes uses on a fixed interval (autoSaveIntervalMs, default 60s), gated by the same dirty flag — a clean editor never fires. The switch is uncontrolled by default and its checked state is persisted to localStorage under pagepilot.editor.autoSave.v1; pass autoSaveEnabled + onAutoSaveChange to control it from the host. Every flip fires onAutoSaveChange so hosts can mirror the flag to their own settings. | | Screen size (Desktop / Tablet / Mobile) | Three-way icon strip that constrains the canvas preview width and keeps the sidebar's per-device tabs in lock-step — switching here also switches which per-device slice the Inspect panel is editing. Hidden via topbar={{ screenSize: false }}. | | Theme toggle ☀/🌙 | Sun/moon icon flips between the built-in light and dark themes. The choice is persisted in localStorage under pagepilot.editor.mode.v1, so a page reload keeps the user's preference — even if props.mode changes. Hidden automatically when the host passes a custom theme prop (no mode to flip). | | 3-dot menu ⋮ | Off by default — enable via topbar.moreMenu. Contains + Add Section and Show / Hide Minimap (the minimap is a click-to-jump / drag-to-scroll canvas overview that docks to the bottom-right corner). | | Fullscreen ⤢ | Toggles the editor's container via the browser Fullscreen API. Icon swaps to "exit fullscreen" while active; pressing Esc exits. | | Preview ▶ | Opens the in-page Preview dialog with desktop/tablet/mobile size toggles. The iframe renders the same HTML that's sent to onSaveChanges / onPublish. If onTogglePreview is supplied, that callback fires instead. | | Publish | Calls onPublish. | | Publish ▾ | Calls onPublishMenu(anchorEl, payload) so the host can open a custom dialog/popover anchored to the button. |

Block TuneMenu

Selecting any block on the canvas surfaces a floating action popover just above it. The strip contains, in order:

| Action | Behavior | |---|---| | ⋮⋮ Drag handle | Native HTML5 drag source. Grab the leftmost dots to drag this block onto another block's drop target and reorder it on the canvas — no need to open the Layers panel. | | ↑ Move up | Swaps this block with its previous sibling under the same parent. | | ↓ Move down | Swaps with the next sibling. | | 🗑 Delete | Removes the block and its subtree. Also prunes any formFields whose sourceBlockId pointed at this block. | | ⧉ Open in Layers | Selects this block and flips the sidebar to the Layers tab so you can see (and drag/reorder) the block inside the tree. | | ✎ Edit Variables | Only appears on blocks that participate in template forms (repeatable containers + blocks bound to formFields). Opens the Variables panel focused on this block's fields. | | ⋯ More menu | Opens the secondary popover below. |

The ⋯ More popover contains:

| Item | Behavior | |---|---| | Save as template | Serialises the selected block into a template configuration. Only wired when the host provides its own persistence path — the standalone build has no built-in "save template" dialog. | | Duplicate | Deep-clones the block (all descendants get fresh ids) and inserts the copy right after the original. | | Copy | Writes the block's configuration to the editor's clipboard slot. Persists across selection changes so you can paste onto a different block. | | Paste | Enabled only when there's a copied block. Appends the copied block as a child of THIS block (id-remapped so nothing collides). | | Replace | Enabled only when there's a copied block. Swaps THIS block in place with a fresh id-remapped copy of the clipboard's block — same position, new content. | | Hide | Toggles the block's canvas visibility (collapses to display: none). Unhide from the Layers panel. |

Add Section flow

  1. User clicks ⋮ → + Add Section (or the empty-state button).
  2. A modal asks for a Title.
  3. On submit:
    • The active section's in-progress canvas document is flushed into record.sections[activeIndex].contentMetadata.document (no edits lost).
    • A new section is appended, seeded with a default empty EmailLayout document.
    • The host receives the updated record via onUpdateRecord.
    • The canvas swaps to the new section's document so the user can immediately edit it.
    • The active step index is bumped.
  4. Once there are 2+ sections, the section dropdown appears in the topbar.

To take over completely, pass onAddSection={() => /* your custom flow */}.

Inline images in a Text block

Double-click a Text block to open the floating formatting toolbar and click the image icon. A dialog prompts for two fields:

  • Image URL — required. Any absolute URL your host serves images from.
  • Alt text — optional. Written to the <img alt="…"> attribute for accessibility.

The image is inserted at the caret position and can then be resized (100% / 50% / 25%), floated left/right, or deleted via the popover that appears when you click it. The standalone editor deliberately doesn't ship a media-library backend — the URL field is the integration point.

Pagepilot AI panel

Currently a Coming soon placeholder. The tab has the purple AiPanelIcon in the rail and opens a centered card that says AI-driven page generation is on the way. When the full generator is ported (prompt input + model picker + voice input, per the ahd-fe reference), the panel body swaps in without any other API changes. Hide the tab entirely via panels={{ pagepilotAI: false }}.

Layout panel

The Layout panel always shows the Blocks picker — a category-grouped tile grid (Text/Content, Media, Layout/Structure, Actions/Controls, Embeds/Integrations, Support/Utility). Add-Block tile visibility is controlled per-block via the blocks prop.

When apiConfig is set, two additional tabs appear at the top of the Layout panel:

  • Templates — global page templates from /global-template?filter[type]=page. Click any card → template preview dialog with three CTAs (Use Template / Use Styles / Add Section). A Templates / Blocks toggle at the top of the tab flips the fetch between type=page and type=block, matching ahd-fe. A refresh icon re-fetches the current list.
  • Saved — per-tenant /mytemplate list. Same card UI with an on-hover delete button.

The Add-Block menu also gains a highlighted Section Library tile (opens a full-screen gallery of block-type templates grouped by category). Without apiConfig, all three affordances are hidden — the Layout panel stays a lean block palette.

Image block sidebar

The Image block's Inspect panel exposes a Media text input for the image URL. There is no Upload Media button — the standalone editor doesn't ship a media backend. Consumers who want an upload flow can either upload through their own host storage before setting record.…url, or wrap <PagePilotEditor> and inject a custom uploader via children if the standalone integration model doesn't fit.

Preview

  • Default behavior (no onTogglePreview): the editor renders every section through renderToStaticMarkup, concatenates the fragments inside a minimal HTML shell (with viewport meta + base font + canvas color from EmailLayout.canvasColor), and shows it in an in-page MUI dialog with desktop / tablet / mobile size toggles. The iframe uses srcDoc (inline HTML) so no network round-trip and no blob-URL flake. The "Open in new tab" header icon is hidden in this mode (nothing to link to — the HTML is inline). A host that supplies a real previewUrl (via internal preview server) gets the icon.
  • Custom UI: pass onTogglePreview={(html, doc) => myDialog.open(html)} to skip the built-in dialog and render your own.

Save / Publish payload generation

When the user clicks Save Changes or Publish, the editor does the following before invoking the callback:

  1. Reads the current canvas document from the internal store.
  2. If the record has sections, flushes the canvas document into sections[activeIndex].contentMetadata.document so the active section is up-to-date.
  3. Renders every section's document via renderToStaticMarkup({ rootBlockId: "root", injectElementIds: true, minifyCss: true, includePageCSS: true, lang, skipLink, accessibilityTools }).
  4. Per-section HTML is written to section.content and per-section documents to section.contentMetadata.document.
  5. All section fragments are concatenated inside a single HTML document with viewport meta, base font, and canvas color from EmailLayout.
  6. Multi-section payload: { record, html } with the record's redundant contentMetadata / empty behaviour / empty steps stripped, and the outer document key omitted.
    Single-doc payload: { record, document, html } where record.contentMetadata carries document + html.

This guarantees Preview ≡ Save Changes ≡ Publish — all three see the exact same rendered output.

Themes

import {
  PagePilotEditor,
  pagePilotEditorDarkTheme,
  pagePilotEditorLightTheme,
} from "pagepilot-visual-editor";

// Built-in themes — these are full MUI Theme objects.
<PagePilotEditor mode="dark" />
<PagePilotEditor mode="light" />

// Or pass your own:
<PagePilotEditor theme={myCustomTheme} />

Both built-in themes carry the editor's custom palette keys (performanceHeader, moduleIcon, moduleListComponentName, grayCloseIcon, codeBlock, brand.blue) tuned per mode so icons and dividers stay legible. If you pass your own theme, you'll likely want to copy those keys.

Runtime toggle + persistence. The topbar's sun/moon button lets the user flip between the built-in light/dark themes at any time; the choice is written to localStorage["pagepilot.editor.mode.v1"] and outranks subsequent props.mode changes so the preference survives page reloads and host re-renders. To hand control back to the host, clear that key. Hide the toggle entirely with topbar={{ themeToggle: false }}.

Isolation from host CSS. The editor wraps its subtree in a MUI ScopedCssBaseline (not global CssBaseline), so it plays nicely with hosts that already run their own MUI theme. ScopedCssBaseline anchors REM sizing at 14 px inside the editor's subtree, so a host with html { font-size: 12px } (or any other non-default) doesn't shrink the editor's layout. The editor root is flex-pinned to fill its parent (width/height 100%, minHeight: 480 floor) — give it a parent with a real height and it fills the rest.

Translations

Two ways:

Per-mount via prop:

<PagePilotEditor
  translations={{
    "pagePilotEditor.tabs.style": "Stile",
    "pagePilotEditor.tabs.layout": "Layout",
    "pagePilotEditor.templatePanel.mainPanel.page.addFirstSection": "Aggiungi sezione",
    // missing keys fall back to built-in English
  }}
/>

Imperatively (from anywhere outside the component):

import {
  setPagePilotEditorTranslations,
  resetPagePilotEditorTranslations,
} from "pagepilot-visual-editor";

setPagePilotEditorTranslations(myDict);   // merges on top of defaults
resetPagePilotEditorTranslations();       // back to English defaults

The full set of keys the editor reads is documented inline at src/host-stubs/i18n.ts in the package.


Block types

Out of the box the Add-Block menu exposes 34 block types, grouped into six categories:

| Text/Content | Media | Layout/Structure | Actions/Controls | Embeds/Integrations | Support/Utility | |---|---|---|---|---|---| | Heading | Image | Container | Button | Timer | Rating | | Text | Logo | Columns | Button Group | YouTube Url | Skip Link | | List | Video | Flex Columns | Accordion | Demo Picker¹ | Accessibility | | Table | Avatar | Grid | Tabs | Header¹ (MenuPicker) | | | | | Spacer | Stepper | Forms¹ | | | | | Divider | | App Banner¹ | | | | | Html | | FAQs¹ | | | | | Carousel | | IFrame | | | | | Pagination | | Calendly | | | | | Marquee | | | |

¹ Picker blocks (Demo / Header / Forms / App Banner / FAQs) surface tenant-managed records. Without host-provided data (via record.contentMetadata.document or a bespoke integration) they render their empty state.

Hide any of them via the blocks prop. Tile labels can be re-translated via translations.


Imperative API (advanced)

The original Pagepilot EditorContext Zustand store is re-exported under the EditorContext namespace so advanced consumers can drive document state imperatively from outside the component:

import { EditorContext } from "pagepilot-visual-editor";

EditorContext.setDocument({ root: ... });
EditorContext.resetDocument(myDoc);
EditorContext.useDocument();          // hook
EditorContext.editorStateStore;       // raw Zustand store

The built-in Reader is also re-exported so you can render saved documents outside the editor:

import { Reader, renderToStaticMarkup } from "pagepilot-visual-editor";

const html = renderToStaticMarkup(myDoc, { rootBlockId: "root" });

renderToStaticHtml(CONFIGURATION, options)

Higher-level renderer that always runs through the same code path the editor uses on Save Changes / Publish: bake formFields, sanitize block types this build doesn't know about (e.g. MdxText), then call renderToStaticMarkup. One function, three call shapes — the first is sync, the API-backed shapes return a Promise<string>.

Zero-dependency subpath (pagepilot-visual-editor/render)

If you only need HTML generation (server-side, serverless, CLI, static export) and don't want to install MUI / Emotion / zustand just to import a render function, use the render subpath. It ships as an editor-free bundle whose only external dependencies are react and react-dom/server:

import { renderToStaticHtml } from "pagepilot-visual-editor/render";
# Only react + react-dom needed for the render subpath.
npm install pagepilot-visual-editor react react-dom

Nothing else changes — the same three call shapes and options documented below apply to both entries. The main entry (import from "pagepilot-visual-editor") still exports the same symbol for hosts that already have MUI installed for <PagePilotEditor>.

1. Sync — render a document you already have in memory

import { renderToStaticHtml } from "pagepilot-visual-editor/render";

const generatedHtml = renderToStaticHtml(CONFIGURATION, {
  rootBlockId: "root",
});
  • CONFIGURATION is the document tree ({ root: { type: "EmailLayout", … }, "block-…": {…} }) — the same shape stored on contentMetadata.document.
  • Any {{token}}s in the doc are resolved automatically using document.root.data.formFields; unknown block types are coerced to Text blocks that carry the original props.html / props.text / props.markdown.

2. Async — fetch a page from the pagepilot API and render every section

const generatedHtml = await renderToStaticHtml(CONFIGURATION, {
  rootBlockId: "root",
  pageId,        // required — page `_id`
  baseUrl,       // e.g. "https://pagepilot.fabbuilder.com/api"
  tenantId,      // required
  authToken,     // optional bearer for private pages
});
  • GET ${baseUrl}/tenant/${tenantId}/page/${pageId}.
  • Every section's contentMetadata.document is baked (with its own formFields) and re-rendered — the stored section.content HTML is never reused.
  • Multi-section pages are wrapped in a <!doctype html> shell (viewport meta, body background/font from the first internal section's EmailLayout). Pass wrapInDocumentShell: false to get bare fragments instead.
  • CONFIGURATION acts as a fallback if the API returns no sections.

3. Async — fetch a template, override variables, render

const generatedHtml = await renderToStaticHtml({}, {
  rootBlockId: "root",
  templateId,    // required — template `_id`
  baseUrl,
  tenantId,      // needed unless `isGlobal: true`
  authToken,     // optional (skipped when `isGlobal: true`)
  isGlobal: false,   // true → GET /global-template/:templateId (public, no auth)
  variables: { fname: "Ishaan", ctaText: "Book Now" },
});
  • isGlobal: false (default): GET ${baseUrl}/tenant/${tenantId}/template/${templateId} with the bearer token.
  • isGlobal: true: GET ${baseUrl}/global-template/${templateId} — public endpoint, no auth header sent.
  • variables accepts two shapes:
    • Flat map — { fieldId: value }. Values are stringified. Ids not on the template are appended as text fields, so {{fname}} still substitutes even if the template's authors never declared a fname field.
    • Array of formField objects — [{ id, name, type, value, itemFields?, sourceBlockId?, rowIds? }, …]. Each entry deep-merges onto the template's existing field with the same id, so you can override array/repeatable fields (fabFaqs, etc.) end-to-end, not just their scalar value.
  • Template envelope shapes handled: flat configuration.root, page-envelope configuration.content.contentMetadata.document, older configuration.contentMetadata.document, and isIndividualBlock block templates (a synthetic EmailLayout root is generated around configuration.rootBlockId).

All options

type RenderToStaticHtmlOptions = {
  rootBlockId?: string;              // "root"
  injectElementIds?: boolean;        // true — emits `id="ahd-<blockId>"`
  minifyCss?: boolean;               // true
  includePageCSS?: boolean;          // true
  lang?: string;                     // falls back to document.root.data.lang
  skipLink?: TSkipLink;              // falls back to root.data.skipLink
  accessibilityTools?: TA11yTools;   // falls back to root.data.accessibilityTools
  injectTabsScrollSyncScript?: boolean;   // true when the doc has a Tabs block
  injectCarouselScript?: boolean;         // true when the doc has a Carousel block
  injectFontAssets?: boolean;             // banners only
  seo?: TSeo;
  headHtml?: string;                 // appended before </head>
  bodyBottomHtml?: string;           // appended before </body>
  isBanner?: boolean;
  // Page-only:
  pageId?: string; tenantId?: string; baseUrl?: string; authToken?: string;
  wrapInDocumentShell?: boolean;     // true — set false to get raw fragments
  // Template-only:
  templateId?: string; isGlobal?: boolean;
  variables?: Record<string, string | number | boolean | null | undefined>
            | Array<Partial<TemplateFormField>>;
  fetchFn?: typeof fetch;            // custom fetch for Node < 18 / tracing
};

Exports

// Component + imperative document setters
export { PagePilotEditor, setDocument, resetDocument } from "pagepilot-visual-editor";

// Themes
export { pagePilotEditorDarkTheme, pagePilotEditorLightTheme } from "pagepilot-visual-editor";

// Translation helpers
export {
  setPagePilotEditorTranslations,
  resetPagePilotEditorTranslations,
} from "pagepilot-visual-editor";

// Reader / renderer
export {
  Reader, ReaderBlock,
  renderToStaticMarkup,       // low-level React → HTML string
  renderToStaticHtml,          // high-level: bake + sanitize + render, also fetches page / template from the API
  ReaderBlockSchema, ReaderDocumentSchema,
} from "pagepilot-visual-editor";

// Imperative store access
export * as EditorContext from "pagepilot-visual-editor";

// Pagepilot API client (for hosts using `apiConfig`)
export { PagepilotApiClient } from "pagepilot-visual-editor";

// Types
export type {
  PagePilotEditorProps, PageRecord, SaveChangesPayload,
  EditorPanelToggles, TopbarToggles,
  BlockKey, BlockToggles,
  PagepilotApiConfig, ListParams, PageListRow, PageListResponse,
  TReaderBlock, TReaderDocument, TReaderBlockProps, TReaderProps,
  RenderToStaticHtmlOptions,
  RenderToStaticHtmlSyncOptions,
  RenderToStaticHtmlPageOptions,
  RenderToStaticHtmlTemplateOptions,
} from "pagepilot-visual-editor";

Development

npm install
npm run dev       # demo at http://localhost:5175
npm run build     # produces dist/index.js + index.cjs + index.d.ts
npm run typecheck

The demo entry is at example/src/main.tsx — it shows every prop in action including theme switching, translations, custom Publish-menu popover, and a seeded multi-section record.


License

MIT