Pulp Engine Document Rendering
Get started

Pulp Engine Visual Editor — Guide

The visual editor (apps/editor) is a browser-based tool for building and editing Pulp Engine template definitions without writing JSON by hand. It runs alongside the existing API and uses the same render pipeline for previews.

The editor is an operator-facing tool intended for internal template authors — access is gated by operator-held credentials, designed for internal team use within your own deployment.

Deployment modes

ContextEditor URLAPI URL
Bundled deployment (Docker image)http://[host]:3000/editor/http://[host]:3000
Local development (pnpm dev from source)http://localhost:5174http://localhost:3000

Bundled deployment: The editor is included in the Docker image and served by the API at /editor/. No separate hosting is required. The editor makes same-origin requests to the API — no CORS configuration needed for the editor itself. For live preview, PREVIEW_ROUTES_ENABLED=true must be set on the API. See docs/deployment-guide.md § Visual Editor for details.

Local development: The editor runs as a separate Vite dev server on port 5174. The API defaults to port 3000. Both start together with pnpm dev from the repo root.

Port conflict with Docker Desktop: Docker Desktop on Windows may bind port 3000. If you run Docker Desktop alongside the dev server, set PORT to any free port in the root .env and create apps/editor/.env.development.local with VITE_API_URL=http://127.0.0.1:<same-port> to match. The .env.development.local file is gitignored — it is a per-machine override and is not committed.

Start the editor in local development:

pnpm --filter @pulp-engine/editor dev
# → http://localhost:5174

Authentication:

When the API has credentials configured, the editor shows a “Sign in” screen on first load. The login form adapts to the credential mode configured on the API server.

Named-user mode (v0.23.0+, EDITOR_USERS_JSON set on the API):

Each team member has a personal key. Enter your personal key in the API Key field and click Sign in. No actor/identity field is shown — your identity is server-verified from the user registry. After login, your display name (or user ID) appears as an identity pill in the editor toolbar.

Shared-key mode (default, no EDITOR_USERS_JSON):

Enter the value of API_KEY_EDITOR (or API_KEY_ADMIN) in the API Key field. Optionally enter a display name or identifier in the “Your name or identifier” field — this operator-supplied actor label is embedded in the session token and appears in server-side audit logs alongside template and asset write events. This is a caller-asserted label, not a verified identity — it is suitable for audit traceability within a trusted team.

OIDC / SSO mode (server has OIDC_DISCOVERY_URL configured):

The login form shows a Sign in with <provider> button above the API-key input (the provider name comes from OIDC_PROVIDER_NAME, default SSO). Clicking it redirects to the identity provider’s login page (PKCE auth-code flow). On successful callback the editor receives a session token the same way the key-based path does and marks the session as OIDC-authenticated so silent refresh uses the OIDC exchange endpoint instead of the editor-token mint endpoint. Key-based login continues to work alongside OIDC — the form shows both affordances. See oidc-guide.md for server configuration.

Common to both modes:

  • The editor exchanges the submitted key for a short-lived session token stored in sessionStorage (cleared on tab close). No VITE_API_KEY or frontend environment variable is needed.
  • API_KEY_EDITOR grants template management, asset management, and preview access — everything the editor needs — without exposing production render endpoints to the browser.
  • In local dev with no server credentials configured, the login gate is bypassed automatically.
  • Session token lifetime defaults to 8 hours and is configurable via EDITOR_TOKEN_TTL_MINUTES on the API server (range: 5–1440 minutes). When the session expires or is invalidated by an operator, the editor returns to the login form and shows a “Your session has ended” notice — re-enter the key to continue.
  • If API_KEY_EDITOR is rotated on the server, outstanding session tokens are immediately invalidated. To rotate without invalidating outstanding sessions, use API_KEY_EDITOR_PREVIOUS — see the operator runbook for the near-zero-downtime procedure.
  • An operator can also invalidate all outstanding sessions without key rotation by setting EDITOR_TOKEN_ISSUED_AFTER (v0.19.0+) — see the runbook for the procedure.
  • In named-user mode, a single user’s sessions can be revoked by setting tokenIssuedAfter on their registry entry — see deployment-guide.md § Named-User Mode for per-user revocation.
  • If the API is unreachable at startup, the editor shows an “API unavailable” error with a Retry button instead of the login form. This prevents confusing API outages with authentication failures.

See docs/api-guide.md § Editor auth for the full auth endpoint reference.

The API must also be running for load-from-API, save, version history, and preview:

pnpm --filter @pulp-engine/api dev
# → http://localhost:3000

Or start both together from the repo root:

pnpm dev

What the editor can do today

CapabilityStatus
Build templates visually (all 20 AST node types including pivotTable, barcode, toc, positioned, templateRef)
Edit node properties in a right-side panel
Outline panel — navigate and reorder nodes
Undo / redo (100-level history)
Drag-and-drop reordering within and across parent containers
Keyboard drag/reorder — Space+Arrow keys on drag handle
Human-readable drag/reorder announcements for screen readers
Inline Handlebars syntax warnings
Unsaved-change protection (confirm before discard)
Dirty-state indicator (• dot in header)
Template source badge (API / local / history copy)
Load a template from the API (by key)
Save / update a template to the API
Version history — browse, open, visual compare, and restore past versions
Version labels — promote specific versions with named labels (e.g. stable, draft)
Scheduled delivery — cron-driven render jobs to email / S3 / webhook targets (requires SCHEDULE_ENABLED; Postgres or SQL Server)
Find / replace across the template tree
Schema explorer — browse inputSchema properties and drop bindings into expressions
Autosave drafts — local-storage safety net for in-progress edits
Embed mode — editor runs inside a customer application via <pulp-engine-editor> custom element
Plugin-registered custom nodes — render, edit, and validate plugin-supplied node types
Load a template from a local JSON file or paste
Export the current template as a JSON file
Preview rendered HTML in-editor (requires API)
Download rendered PDF (requires API)
inputSchema editor — structured field panel
fieldMappings editor — structured source → target panel with jexl expression field
Document tab — page margins, header/footer HTML authoring
Header/footer snippet buttons (confirm-before-replace guard)
Navigable validation panel — errors and warnings with node jump
Editor session token authentication (login gate + 8-hour HMAC token)
Handlebars / expression validation✓ (syntax errors, unknown helpers, missing helper arguments, invalid date format literals, unknown binding paths, jexl expression syntax, required field checks — full evaluation at render time)
Multi-template tabs — multiple templates open simultaneously
Duplicate / Save As — copy current template to a new key
Command menu — keyboard-driven quick actions (Ctrl+K / Cmd+K)
Image asset management — upload, browse, and delete image files
Paper size and orientation — configurable per template in Document tab
richText inline canvas editing — double-click (or Enter) to edit on canvas
richText Visual editor — Tiptap toolbar with formatting, colour palette, lists, links
richText accessibility — WCAG-compliant toolbar, roving tabindex, aria-pressed, focus rings
Empty-state affordances on heading, text, table, and richText nodes
Per-user named credentials and identity pill (v0.23.0)
OIDC / SSO login (when server has OIDC_DISCOVERY_URL configured)
AI template generation from a natural-language prompt (requires ANTHROPIC_API_KEY on server)
Collapsible property groups — expandable/collapsible sections in the properties panel
Per-node comments — attach editorial notes to any node (500-char limit)
Node templates / favorites — save and reuse common node configurations
Template linting hints with dismissals — best-practice suggestions with per-template dismissal
Visual expression builder — point-and-click JEXL expression editor with live preview
Handlebars playground — interactive expression testing with helper reference and history

Header at a glance

Pulp Engine  [• My Template]  [v1.0.4]  [API]   ↩ ↪  History  Preview  New  Load Sample  Open  Load JSON  Duplicate…  Alice Smith  Update*
──────────────────────────────────────────────────────────────────────────────────────────────────────
[ My Template × ]  [ Invoice × ]  [ + ]         ← tab bar
  • Identity pill (named-user mode only) — shows the logged-in user’s display name (or user ID if no display name is set). Hovering shows “Signed in as …”. Not shown in shared-key mode or when no actor is stored.

  • • dot after the template name — unsaved changes exist

  • Version badge — the version currently loaded (updates to server version after save)

  • Source badge — shows where the template came from:

    • API (blue) — loaded from the API; Save will PUT to update it
    • local (grey) — created from scratch, a file, or Load Sample; Save will POST to create it
    • history copy (amber) — opened from version history; treated as a local copy until saved
  • Save button — labelled Update when in API mode, Publish when in local/history mode


Loading a template

From the API

  1. Click Open in the header.
  2. The picker fetches the template list from GET /templates and displays each template’s name and current version.
  3. Click Open next to the template you want.
  4. The editor loads it in API mode — the source badge shows API.

If the template list fails to load, a Retry button appears inline. The Cancel button is always available.

If you have unsaved changes, a confirmation prompt appears before the current template is replaced.

From a local file or JSON paste

  1. Click Load JSON in the header.
  2. Either click Open JSON file… and select a .json file, or paste a TemplateDefinition JSON object directly into the text area.
  3. Click Load.

The editor validates the top-level structure before loading (key, name, version, inputSchema, document must all be present). Detailed Handlebars or data-path errors are only caught at render time.

This template is in local mode — the source badge shows local.

Load Sample

Click Load Sample to load a built-in loan-approval-letter skeleton. Useful for exploring the editor without a database connection. Loads in local mode.

New template

Click New to open the new-template dialog. Fill in a key, name, and version (semver), then choose a starting point from the 25 built-in options in the dialog (Blank plus 24 tabbed packs).

Blank is pinned at the top as the default — an empty document with one section. When the server has AI template generation enabled (see below), a second pinned card ✨ Generate with AI sits next to Blank. Below the pinned row, packs are organised into four category tabs:

TabPacks
DocumentsLetter, Proposal, Meeting Minutes, Report, Certificate, Contract, NDA, Event Agenda, Product Sheet
FinancialInvoice (Recommended), Quote, Receipt, Credit Note, Expense Report, Timesheet, Statement
HROffer Letter, Pay Stub, Performance Review
OperationsPacking Slip, Purchase Order, Inventory Report, Sales Pivot (Single Dimension), Sales Pivot (Multi-Level + Subtotals)

Each pack card shows a short description and a bestFor label explaining the intended use case. The Invoice pack carries a Recommended badge. Selecting a pack auto-fills the template name from the pack’s default name.

Themed vs Plain

Once a non-Blank pack is selected, a Style toggle appears with two options (defaulting to Themed):

  • Themed — packs ship with a per-category palette (deep blue for Financial/Operations, teal for Documents/Commercial, indigo for HR, graphite for general correspondence). h1 headings, accent dividers, themed table header rows, alternating body row tints, and accent footer/total rows all pick up the category palette.
  • Plain — strips theme colours/backgrounds while preserving structural styles (textAlign, fontWeight, fontSize, margins, paper size). Useful when you want to apply your own brand styling from scratch.

The toggle runs as a post-process pass over the generated template (applyPlainTheme(def)) — packs are authored once with theming baked in, and Plain mode walks the document tree dropping color, backgroundColor, borderColor / borderTop / borderBottom from style objects and the table row-style sets (headerRowStyle, bodyRowStyle, alternateRowStyle, footerRowStyle, totalRowStyle, tableStyle). Layout properties survive so the document keeps its structure.

The two new Sales Pivot packs in the Operations tab demonstrate the pivot table v1 (single row dimension, grand totals only) and v2 (multi-level row dimensions with subtotals) capabilities — useful as both feature showcases and starting points for hierarchical reports.

The editor creates the template definition from the selected pack and loads it in local mode.

All four entry points will prompt you to confirm before discarding unsaved changes.

Generate with AI (opt-in)

When the server has ANTHROPIC_API_KEY configured, the new-template dialog shows a ✨ Generate with AI card next to Blank. Click it and the category-tabs grid is replaced with a prompt panel:

  1. Type a natural-language description of the template you want — for example, “a one-page invoice with a header, a 3-column line-item table, and a total row at the bottom”. Prompts must be between 10 and 4000 characters; the counter under the textarea turns red if you go over.
  2. Click Generate. The button shows a spinner and a Cancel button appears next to it. Generation typically takes 20–40 seconds (the editor waits up to 2 minutes before timing out).
  3. You’ll know it worked when the four metadata fields below the panel auto-populate from the generated template’s key / name / version / description, and the Create button (previously disabled) becomes enabled. There is no separate confirmation banner — the form’s state transition is the success signal. You can edit any of the auto-populated fields; your edits override the AI’s choices when you click Create.
  4. Click Create as usual. The generated template loads into a fresh editor tab as an unsaved draft, ready for review and editing. The editor automatically seeds synthesized preview data (matching the template’s inputSchema) into the sample-data panel, so your first preview/publish attempt passes without manual JSON entry. The synthesized values are valid but plain — strings show "Sample Text", numbers show 0, dates show 2025-01-15. Open the Preview panel’s data tab to replace them with realistic values for a better preview. Save the template through the normal Save flow.

You can Cancel at any point during generation, or close the dialog (Escape, click-outside, or the X) — the in-flight request is aborted cleanly. If generation fails, an inline error banner explains why and the Generate button re-enables for a retry. Common errors:

ErrorMeaning
”You’ve hit the generation rate limit. Wait a minute and try again.”The server caps generations per credential (default 5/min). Wait and retry.
”The AI couldn’t produce a valid template. Try rephrasing your prompt to be more specific.”The model produced output that failed schema validation across all retry attempts. Usually means the prompt is too vague or asks for an unsupported shape.
”Generation took longer than 2 minutes. Try a shorter or simpler prompt.”Hit the 2-minute editor timeout. Long prompts and complex layouts are more likely to time out.
”AI generation isn’t enabled on this server. Refresh the page — capabilities may be out of date.”The server’s ANTHROPIC_API_KEY was unset after your editor loaded. Refreshing fetches the current capabilities.

The AI card is invisible when the server has not enabled the feature — there is no greyed-out fallback. The editor checks GET /capabilities on first mount; if you enable the feature on the server while the editor is already open, you need to refresh to see the card. Token usage and per-call cost details are not shown in the editor — operators can query GET /audit-events?event=template_generation for those, which is the system of record for AI generation cost.


Saving a template

Publish-readiness check

Before any save or publish is allowed, the editor runs a two-phase check:

  1. Static validation — checks Handlebars syntax, JEXL expression syntax, data path validity, link safety, field mapping coverage, and missing static asset references. Issues with error severity block publish; warning-severity issues are advisory.
  2. Render validation — calls POST /render/validate with the current template definition and sample data. This is a publish-readiness validation check that verifies the template is structurally valid and renderable. It never returns HTML or PDF output.

POST /render/validate is always available in production — it does not require PREVIEW_ROUTES_ENABLED. Operators do not need to enable preview routes just to publish templates.

If the API is unreachable (network failure) or returns an authentication, rate-limit, or server error, publish is blocked. Restore API connectivity and try again. The render check must complete successfully before publish is allowed.

Publish gate outcomes

The publish gate produces one of six outcomes:

OutcomeMeaningUser action
All checks passedStatic validation and render preflight both succeededClick Publish / Update
Blocking issuesStatic checks found errors (bad syntax, missing assets, etc.)Fix the listed issues
Sample data must be a valid JSON objectThe sample data in the Preview panel is not a JSON object (malformed, array, null, or other non-object value) — preflight cannot runOpen Preview, fix the sample data, then try again
Asset list could not be loadedThe API could not be reached while checking for referenced assetsRestore API connectivity and try again
Template failed to renderStatic checks passed; server returned a classified render or validation errorFix the template expression or data mapping
Validation check could not be completedNetwork failure, authentication error, rate limit, or server error prevented the checkRestore connectivity or check server health, then retry

API-loaded template (Update)

If the template was loaded via Open (from the API), clicking Update sends a PUT /templates/:key request. The editor automatically includes the loaded version as an If-Match precondition header, so concurrent writes from other users are detected rather than silently overwritten. The API auto-bumps the patch version (e.g. 1.0.2 → 1.0.3) and returns the new version. A success flash shows Updated to v1.0.3.

The save resets the undo history — treat it as an intentional save boundary.

Local / sample / file-loaded template (Publish)

If the template was loaded from a file, Load Sample, or created from scratch, clicking Publish sends a POST /templates request to create a new database record.

After a successful create, the template switches to API mode and subsequent saves will use PUT.

History copy (Publish)

Opening a past version from the version history creates a local copy (source badge: history copy). Clicking Publish will POST it as a new template unless you first change the key to a new unique value, or discard it and use the main template instead.

If the key already exists on the server, publish will fail. Change the key, or open the existing live template explicitly if your intent is to modify that template.

Save feedback

OutcomeFlash message
Successful updateUpdated to v1.0.X
Successful createCreated v1.0.0 (or whatever version was set)
Key conflict on createA template with this key already exists. Change the key or open the existing template instead.
Concurrent write conflictConflict: this template was modified by someone else. Reload from the API to see the latest version.
Network errorError text from the API or fetch exception

Save conflict recovery

A conflict means another user (or another browser tab) saved a newer version of this template while you had it open. Your changes were not saved.

To recover:

  1. Click History (or Ctrl+K → Version History) to see what the latest version contains.
  2. Click Open in the header and reload the template from the API to get the current version.
  3. Re-apply your changes on top of the latest version and click Update again.

The conflict error is shown immediately — no data is lost on your side. Your unsaved changes remain in the editor until you navigate away or close the tab.


Preview panel

The preview panel renders the current template and sample data as HTML or PDF using the Pulp Engine API. It requires PREVIEW_ROUTES_ENABLED=true on the API and a working Chromium installation (for PDF). The panel checks preview availability on open and fails closed if the service cannot be confirmed available. If the check fails with a recoverable error, a Retry button appears in the panel so you can re-check without closing and reopening.

Preview panel states

Panel showsMeaningWhat to do
”Checking preview availability…”Startup check in flightWait — the check is fast and cached on the backend
”Preview unavailable” (routes disabled)PREVIEW_ROUTES_ENABLED is not set or is false on this serverEnable preview routes or contact your administrator
”Preview unavailable” (rendering engine)Chromium failed to launch at API startupCheck API logs for Chromium/Puppeteer errors; verify your deployment environment
”Preview service unreachable” + RetryThe status check could not connect to the API at all (network failure)Check that the Pulp Engine API is running and accessible, then click Retry
”Preview check failed (HTTP N)” + RetryThe status endpoint returned an HTTP error (401, 403, 429, 500, etc.)Check authentication credentials, rate limits, or server health, then click Retry
”Preview check failed” (unexpected response) + RetryThe status endpoint returned a 200 response the editor could not interpretCheck that the Pulp Engine API version is compatible with this editor version, then click Retry
Idle guidance — “Click Render HTML…”Preview available (or older backend without status endpoint)Click Render HTML or Proof PDF
Render error bannerPreview available but the render itself failedSee the error kind and message; fix the template or data, then retry

Note: On older API backends that predate GET /render/preview/status (which always returns 404), the panel shows idle guidance and lets you attempt a render. If the API is reachable and the render endpoints work, rendering will succeed. If the backend is genuinely unreachable, the render attempt will fail with an “API unavailable” error.

Capability freshness: The preview availability check is re-run automatically when you return to an editor tab and the last check is more than 5 minutes old — no manual Retry required. This covers transient failures (service_unreachable, HTTP errors, unexpected responses) and successful states (detecting if the service has since gone down). Operator-level states (routes disabled, rendering engine unavailable) are not re-checked automatically — they require a server-side fix regardless.


Version history

Version history is available when an API-backed template is open.

  1. Click History in the toolbar (only visible for API-backed templates), or use Ctrl+KVersion History.
  2. The history modal lists all saved versions newest-first. The currently loaded version is highlighted with a current badge.
  3. Each past version has three actions:
    • Compare — opens a text diff view showing what changed between that version and the current one
    • Open as copy — opens the version as a local draft in a new tab. The source badge shows history copy. Publish will create a new template with a different key.
    • Restore to this version — immediately restores the live template to this version (admin scope required). The current tab reloads automatically. Editor-scoped credentials will receive a permission error.

Visual Compare

From the Compare view, click Visual Compare in the footer to open a full-screen side-by-side rendered diff. The left pane shows the past version, the right pane shows the current version. Structural changes are highlighted:

HighlightMeaning
Green left borderNode added in the current version
Red left borderNode removed (present in past version only)
Orange left borderNode modified (properties changed)
Blue left border + dashed outlineNode moved to a different location

The left panel lists all changes grouped by kind. Click any entry to scroll both canvases to that node. Toggle Sync scroll to keep both panes aligned while scrolling manually.

Which action to use

GoalAction
Roll back the live template to a past versionRestore to this version (admin only)
Fork a past version to a new template keyOpen as copy, then change the key, then Publish
Inspect or preview a past version without changing anythingOpen as copy or Compare

Drag-and-drop reordering

Nodes can be reordered and moved across containers by dragging.

  • Sections — a handle is always visible at the top-left of each section card. Grab it and drag up or down to reorder sections within the document.
  • Content nodes (text, heading, table, container, conditional, repeater) — a faint handle is always visible at the left edge of each node. It brightens on hover or when the node is selected. Drag it to reorder within the same parent or move it to a different container, column, conditional branch, or repeater branch. A dashed indigo outline highlights the drop target as you drag.

Empty containers show a Drop here zone during any active drag, making it easy to populate containers that have no children yet.

The ↑/↓ buttons in the action bar remain available for keyboard-friendly reordering.

Keyboard reordering

All drag handles are keyboard-accessible. When a drag handle has focus:

  1. Press Space to pick up the node.
  2. Press Arrow Up / Arrow Down to move the node within its parent container.
  3. Press Space again to drop it at the new position.
  4. Press Escape to cancel and restore the original position.

A live-region announcement is spoken on pick-up, move, and drop, naming the node so screen readers can describe the action (e.g. “Heading: Loan summary. Position 2 of 4. Use arrow keys to move, Space to drop, Escape to cancel.”).

While a node is being keyboard-dragged, pointer-drag on other nodes is suppressed. Drag handles on richText nodes in inline-edit mode have their tabIndex removed so they do not interrupt the text-editing keyboard flow.


Handlebars warnings

Content fields that accept Handlebars expressions (text body, heading text, image src, table column headers and footers) show inline warnings as you type. Warnings are advisory — they never block save or publish.

Warning typeExample triggerMessage
Syntax error{{unclosedParser error message
Unknown helper{{currancy total}}Lists available helpers
Missing argument{{currency}}Shows correct usage, e.g. {{currency amount}}
sum needs two args{{sum items}}sum requires 2 arguments: {{sum arrayField "fieldName"}}
Invalid date format literal{{date field "weekly"}}date format "weekly" is not recognised — use: short, medium, long, or full
Unknown binding path{{unknownField}}Checked against inputSchema when the schema is populated

Available helpers: currency, number, date, sum, uppercase, lowercase.

The format check for date only fires when the format argument is a string literal. If it is a variable reference (e.g. {{date field fmt}}), no warning is shown.

conditionExpression fields (jexl escape hatch on conditional nodes) are syntax-checked using the jexl compiler. Invalid expressions appear in the validation panel.

Full Handlebars evaluation (context resolution, runtime helper output) still happens at render time via the API.


Collapsible property groups

The properties panel organises node settings into collapsible groups. Each group has a header that can be clicked to expand or collapse its contents. Groups include Style, Pagination, Expression override, and others depending on the node type.

  • Groups default to open unless the section is secondary (e.g. expression overrides default to collapsed).
  • Open/closed state resets when a different node is selected.
  • Group headers may include a badge showing a count (e.g. “Columns (3)”).

Per-node comments

Any node can have a free-text comment attached for editorial notes, reminders, or review annotations. Comments are stored in node.meta.comment and do not affect rendering.

Properties panel: An expandable comment section appears at the top of the properties panel for the selected node. Type a comment (up to 500 characters), and it saves automatically on blur. A character counter shows current/max length. Click the clear button to remove the comment.

Canvas: Nodes with a comment show a * indicator on the node badge. Click the indicator to open a popover showing the comment text.


Node templates / favorites

Commonly used node configurations can be saved as reusable block templates. This is useful for standardised content blocks that appear across multiple templates (e.g. a disclaimer section, a standard table layout).

  • Save the current node configuration as a block template via the canvas action menu.
  • Browse and insert saved templates from the block palette.
  • Manage saved templates through the template picker dialog.

Block templates are stored locally and are available across all templates in the editor session.


Template linting hints

In addition to errors and warnings, the editor surfaces hints — best-practice suggestions that do not block publish. Hints appear in the validation panel alongside errors and warnings, with a distinct visual treatment.

Hint ruleTriggerSuggestion
heading-level-skipH2 → H4 jumpUse sequential heading levels
wide-tableTable with > 8 columnsConsider splitting into multiple tables
unlabeled-sectionSection without a labelAdd a label for outline navigation
image-without-altImage node without alt textAdd alt text for accessibility
empty-else-branchConditional with empty elseRemove the else branch if unused
deep-nestingNesting > 4 levels deepSimplify the document structure
unused-schema-fieldSchema field not referenced in document or mappingsRemove unused fields or add references

Dismissing hints: Each hint can be dismissed per template. Dismissed hints are stored in localStorage and do not reappear until the underlying condition changes (e.g. the node is modified). Click the dismiss button next to any hint in the validation panel.


Conditional logic builder

Conditional nodes use a visual boolean rule builder in the properties panel. Non-developers can compose multi-clause conditions — e.g. show this section if customer.country == "US" and amount > 1000 — without writing code.

Structure. The builder edits a tree of clauses:

  • A leaf clause is a single path / operator / value row (eq, ne, gt, gte, lt, lte, exists, notExists). Paths autocomplete from inputSchema.
  • A group clause combines child clauses with AND or OR, optionally wrapped in NOT. Groups can nest arbitrarily deep.

Controls per group. Toggle AND/OR, check NOT to negate, and use Add condition / Add group / ✕ to edit the tree.

Live evaluation. Each leaf and group shows a pill (true / false / ) evaluated against the current preview data. The panel is fail-safe: incomplete clauses (empty path, missing value) render and never throw while editing.

Equivalent expression. A read-only JEXL preview appears under the tree (e.g. customer.country == "US" && amount > 1000), helping users learn the underlying syntax. It is informational only — the structured tree is the source of truth at render time.

Storage & back-compat. Existing single-clause templates load and save byte-identically (single-leaf AND groups collapse to a bare StructuredCondition on save). No migration is required.

Expression override. The conditionExpression (JEXL) override is still available in a collapsible section. When set, it takes precedence over the visual tree and the tree renders read-only with a “Clear override to edit visually” hint.

Visual JEXL expression builder

The conditionExpression override offers its own visual JEXL pattern builder as an alternative to writing raw JEXL syntax.

Supported patterns:

PatternExampleDescription
Comparisonstatus == "approved"Field compared to a value (==, !=, >, >=, <, <=)
Arithmeticprice * quantityField combined with another value (+, -, *, /)
ConcatenationfirstName + " " + lastNameMultiple field/literal segments joined
Ternaryactive ? "Yes" : "No"Conditional value selection

Mode switching: The builder auto-detects the pattern from existing JEXL text. You can toggle between visual and raw modes at any time. Expressions that do not match a supported pattern are shown in raw mode only.

Live preview: The builder shows the evaluated result of the expression against the current sample data from the Preview panel.

Schema awareness: The field selectors suggest available fields from inputSchema.


Handlebars playground

The Handlebars playground is an interactive testing environment for authoring and debugging Handlebars expressions. Open it from the editor toolbar or via Ctrl+KHandlebars Playground.

  • Expression input: Type any Handlebars expression (e.g. {{currency amount}}, {{#if active}}Yes{{/if}}).
  • Live evaluation: The expression is evaluated against the current preview data (with field mappings applied) after a 200 ms debounce. Results or syntax errors are shown immediately.
  • Helper reference: A collapsible panel lists all available helpers with their argument counts and usage examples.
  • Expression history: The last 10 expressions are saved in localStorage. Click any history entry to re-run it.
  • Auto-population: When opened with a text or heading node selected, the playground pre-fills with that node’s content.

The playground uses the same Handlebars engine as the properties panel — it does not call the API. Full server-side evaluation (context resolution, runtime helper output) happens at render time.


Validation

The editor runs structural and syntax checks as you edit and shows an issue badge in the header:

  • Red badge — one or more errors (e.g. empty required fields, invalid Handlebars syntax, invalid jexl expression in conditionExpression or field mapping)
  • Yellow badge — warnings only
  • Green dot — no issues

Click the badge to open the issues panel — a dropdown listing all errors and warnings. Click any row to jump directly to the relevant node in the canvas.

Checks cover:

  • Empty content fields (text, heading, image src, table dataPath)
  • Handlebars syntax errors in any content string
  • Unknown Handlebars helper names in text, heading, image, and table header/footer fields
  • Helper calls with too few arguments, and date helper with an unrecognised format literal
  • Table column format field set to an unrecognised helper name
  • jexl syntax errors in conditionExpression and field mapping expression fields
  • Missing required node properties
  • richText: invalid colour format in a TextLeaf (must be #rgb, #rrggbb, rgb(), rgba(), hsl(), or hsla()) — flagged as an error
  • richText: link with no href, or an href using an unsafe protocol — flagged as an error
  • richText: Handlebars syntax errors in TextLeaf.text content — flagged as an error

Full Handlebars evaluation (context resolution, runtime helper output) still happens at render time via the API.


Undo / redo

  • Undo: Ctrl+Z / Cmd+Z (or the ↩ button in the header)
  • Redo: Ctrl+Shift+Z / Cmd+Shift+Z / Ctrl+Y (or the ↪ button)

History tracks all node edits, additions, deletions, moves, and reorders. It is reset when:

  • A new template is loaded (Open, Load Sample, Load JSON, New, or opening a history version)
  • A template is saved (the save version sync triggers a reload into the store)

History is in-memory only — it does not persist across page refreshes.


Multi-template tabs

Multiple templates can be open simultaneously. Each template occupies its own tab in the tab bar displayed below the main header. Each tab maintains independent state: dirty indicator, undo/redo history, source badge, and version.

Opening a tab: Any load action — New, Open, Load Sample, Load JSON, or opening a version from history — opens the template in a new tab (or replaces the current tab if it is unmodified and in local mode).

Switching tabs: Click any tab in the tab bar. You can also use Ctrl+KSwitch Tab group, which lists all open tabs with the current one marked and disabled.

Closing a tab: Click the on the tab. If the tab has unsaved changes, a confirmation dialog appears before discarding. You can also close the active tab via Ctrl+KClose Active Tab.


Duplicate / Save As

Duplicate… opens a Save As dialog that creates a copy of the current template under a new key and name. The copy opens in a new tab in local mode — it is not saved to the API until you click Publish.

Access it via the Duplicate… button in the header or via Ctrl+KDuplicate Template. The button is disabled when no template is open.

This is useful for branching a template (e.g. creating a loan-approval-letter-v2 while keeping the original intact) or for seeding a new template from an existing structure.


Command menu

Press Ctrl+K (Windows/Linux) or Cmd+K (Mac) to open the command menu. The shortcut is ignored when focus is in a text input, textarea, or content-editable field.

The command menu is organised into groups:

GroupCommands
FileNew Template, Open Template, Load Sample, Load JSON, Export JSON, Duplicate Template, Close Active Tab
TemplatePreview, Publish / Update Template, Version History
NavigateProperties Tab, Schema Tab, Mappings Tab, Document Tab, Blocks Panel, Outline Panel
Switch TabLists all open tabs by name; current tab is marked and disabled (only shown when 2+ tabs are open)

The Publish / Update label and the Version History item reflect the current template’s source — they are disabled when no template is open or when the template is not API-backed (for Version History).


Document tab

The Document tab in the right-side panel (alongside Properties, Schema, and Mappings) contains template-level rendering settings that apply to every page of the PDF output.

Paper size and orientation

Select the paper size and orientation for the rendered PDF. These map to renderConfig.paperSize and renderConfig.orientation.

SettingOptionsDefault
Paper sizeA4, A3, Letter, Legal, TabloidA4
OrientationPortrait, LandscapePortrait

Changing these settings affects PDF output only — HTML preview renders without page boundaries.

The render API also accepts per-request options.paperSize and options.orientation overrides that take precedence over the template setting. See docs/api-guide.md.

Page margins

Top and bottom margin inputs accept CSS length values (e.g. 20mm, 1.5cm). The full margin set (top, right, bottom, left) is stored in renderConfig.margin. Setting top or bottom here merges with the existing left/right values.

Set top/bottom margins to ≥ 16mm when using a page header or footer — they render inside the margin area and will overlap body content if the margin is too small. An advisory warning appears automatically when the margin is detected to be under 15mm.

pageHeader and pageFooter are HTML strings stamped on every page of the PDF by Puppeteer. They are not shown in HTML preview — use the ↓ PDF button in the preview panel to verify page chrome.

Constraints:

  • Inline styles only — body CSS and customCss do not apply inside headers/footers
  • Puppeteer sets font-size: 0 by default; set it explicitly in your div
  • Span classes injected by Puppeteer: pageNumber, totalPages, date, title, url

Snippet buttons offer common starting points (page number, page X of Y, date, title + page). Clicking a snippet when the textarea already has content triggers a confirmation prompt before replacing. Snippets are undo-tracked.

Security note: pageHeader and pageFooter HTML is allowlist-sanitized at render time before passing to Puppeteer. Obvious script, event-handler, and external-resource vectors are stripped automatically (see sanitizeHeaderFooter in pdf-renderer). The allowlist covers the elements and CSS properties used by the built-in snippets and common hand-authored headers/footers; content using only supported elements does not require special precautions. Residual sanitizer and Chromium risk is not zero — do not rely on this as a complete security boundary for content from fully untrusted sources. Both fields are capped at 10,000 characters.

Rendered-page security posture

The main rendered HTML document (body content, customCss, font imports) benefits from additional browser-layer hardening:

  • Script execution blocked at browser level: every rendered page includes a Content-Security-Policy meta tag with script-src 'none'; object-src 'none', blocking JavaScript execution even if a future sanitizer bypass allows hostile content through. Note this CSP applies to the main document only — Puppeteer’s headerTemplate/footerTemplate contexts operate separately.
  • Network requests intercepted: all outbound requests from the Chromium renderer are subject to SSRF guards blocking private IPv4, RFC 1918, link-local (IPv4 and IPv6 fe80::/10), IPv4-mapped IPv6 (::ffff:), and ULA IPv6 ranges. Font URLs additionally undergo application-layer validation before being emitted into CSS.

These are defences for trusted-team use. Pulp Engine is not designed for environments where template authors are fully untrusted or adversarial.


RichText node editing

richText nodes can be edited in two ways: directly on the canvas (inline editing) or in the Properties panel. Both surfaces use the same Tiptap-based editor.

Inline canvas editing

Double-click any richText node on the canvas to open the editor inline without leaving the document view. Press Enter with the node selected to do the same.

  • The editor appears inside the node bounds at full canvas width.
  • All toolbar operations (formatting, colour, lists, links, line breaks) work exactly as in the Properties panel.
  • Press Escape to close the inline editor and return focus to the node wrapper.
  • The Properties panel shows a status notice (“editing inline on canvas”) when inline editing is active for the current node, and the Visual / JSON tabs remain fully accessible.
  • The inline editor exits automatically when a different node is selected.

Visual mode

A Tiptap-based editor with a formatting toolbar. Supported operations:

Toolbar itemEffect
B BoldToggles bold mark on selected text
I ItalicToggles italic mark on selected text
U UnderlineToggles underline mark on selected text
Colour swatchOpens a compact colour palette; click a preset or “No color” to apply/remove the color mark
Bullet listToggles an unordered list
Numbered listToggles an ordered list
LinkOpens an inline popover to set or edit a link URL
Shift+EnterInserts a hard line break (lineBreakInline)

Colour palette: The toolbar shows a compact 3-column palette of 8 preset colours (Black, Dark gray, Red, Amber, Green, Blue, Purple, Pink) plus a “No color” option to remove the mark. Clicking a swatch emits a #rrggbb hex value. Existing rgb(), rgba(), hsl(), or hsla() colour values are preserved on round-trip. Named colours (e.g. red) are not supported and will be flagged as an error by the validator.

Nested lists: One level of list nesting is supported. Indent a list item to create a sublist; outdent to collapse it. Attempting to nest deeper has no effect — the AST caps nesting at one level.

Handlebars expressions: Handlebars expressions (e.g. {{fieldName}}) are preserved as plain text in Visual mode. A hint in the toolbar reminds you that JSON mode is better for authoring or editing expressions precisely.

Toolbar accessibility: The toolbar is a single role="toolbar" region with roving tabindex navigation. Tab moves focus into the toolbar; Arrow Left / Right navigate between items; Home / End jump to the first / last item. Format toggle buttons have aria-pressed reflecting their active state. Tab moves focus out of the toolbar.

Clicking the link toolbar button opens an inline popover:

  • URL input — pre-filled with the existing href when editing a link; blank when creating a new one
  • Save — applies the link; a blank URL removes the link; bare domains (e.g. example.com) are automatically prefixed with https://
  • Remove — removes the link and returns the text to unstyled
  • Cancel — discards changes

Accepted protocols: https://, http://, mailto:, tel:. The popover shows a warning (but still allows saving) when an unsafe protocol such as javascript: or ftp:// is entered. The server renderer and validator also reject unsafe protocols. Bare domains without a protocol are normalised to https:// on save.

JSON mode

A raw JSON textarea that accepts the full RichTextBlock[] content array. Use this mode for:

  • Authoring or editing Handlebars expressions precisely (e.g. {{fieldName}} in text leaves)
  • Constructing content structures that are difficult to express in Visual mode
  • Pasting content from an external source

The rich-opening node in loan-approval-letter is a working reference — it demonstrates a paragraph with Handlebars, bold and colour marks, a one-level nested list (one level is the maximum supported depth), two links, and a line break. Open it in JSON mode to see the full RichTextBlock[] shape.

The textarea shows an inline parse-error message if the JSON is malformed. Changes sync to the template on blur. A schema-hint toggle is available below the textarea.

JSON mode is the lossless fallback — switching between Visual and JSON tabs round-trips content through the AST, which may normalise whitespace or merge adjacent text leaves with identical marks.

Tab mode persistence

The active tab (Visual or JSON) is remembered in the editor store. Switching between nodes preserves the last-used tab so the workflow is not interrupted by repeated tab switching.


Image assets

The editor integrates with the API’s asset storage to manage image files used in templates.

Uploading assets

Assets are managed through the API and are accessible to all templates. To upload:

  1. The editor fetches the asset list from GET /assets on load and refreshes it after any upload or delete.
  2. Upload a file via POST /assets/upload (multipart/form-data). The editor exposes this through the image node property panel.
  3. Accepted types: PNG, JPEG, GIF, WebP. Maximum file size: 10 MB. SVG is not accepted — SVG files can contain JavaScript and external entity references.

Using assets in image nodes

Once uploaded, an asset’s URL (e.g. /assets/uuid-logo.png) can be used directly as an image node src value. The src field accepts any Handlebars string — you can use a static path, a data-bound variable, or a mix.

Broken reference validation

The editor compares image src values that start with /assets/ against the loaded asset list. If the referenced file is not found in the list, an error is shown in the validation panel. This catches stale references when an asset has been deleted.

Missing static asset references block publish. Upload the missing file or update the image src before publishing. The check only fires when src is a static /assets/ path; data-bound references (e.g. {{logo.url}}) are not checked and do not block publish.

Asset access in private mode

When the API is configured with ASSET_ACCESS_MODE=private, asset images cannot be loaded via a direct <img src> tag (the browser cannot send X-Editor-Token headers on image requests). The editor handles this automatically:

  • Each asset tile in the image picker and each thumbnail in the properties panel fetches the binary via the Fetch API with the X-Editor-Token header.
  • A temporary blob URL is created and passed to the <img> element. The blob URL is revoked when the component unmounts or the asset URL changes.
  • In dev mode with no credentials (auth disabled), asset URLs are used directly without authenticated fetch.

Known limitations and rough edges

LimitationNotes
Save resets undo historyTreated as a save boundary; intended
Helper validation is advisory onlyUnknown helpers, missing args, and invalid format literals are warned but not blocked. Complex helper expressions (e.g. nested sub-expressions) are not parsed for argument checks to avoid false positives.
Binding path validation requires a populated schemaUnknown-path warnings are suppressed until inputSchema has at least one property defined
Asset broken-ref check blocks publish/assets/ path errors block publish but do not block save. Data-bound src values are not checked.
Asset storage defaults to local filesystemAssets are stored on the API host by default (ASSET_BINARY_STORE=filesystem). Set ASSET_BINARY_STORE=s3 for S3-compatible object storage — eliminates the shared-volume requirement for multi-instance deployments. See deployment-guide.md § Object Storage.
richText Visual mode: Handlebars expressions preserved as plain textVisual mode treats {{expr}} as literal text — use JSON mode for authoring or editing Handlebars expressions precisely
richText nested lists: one level onlyThe AST supports one level of sublist nesting; attempting to nest deeper in Visual mode has no effect
richText link popover: partial URL validationThe popover warns on unsafe protocols and normalises bare domains to https://; the renderer and validator enforce safe protocols at render time
richText Visual mode: local undo history clears when global undo changes node contentWhen global Ctrl+Z restores a richText node to an earlier version (because the edit was made outside the Visual editor), Tiptap’s local undo history starts fresh from the restored content. This is intentional — the document baseline changed. Use global Ctrl+Z / Ctrl+Shift+Z to navigate document-level history; local Ctrl+Z inside Visual mode undoes fine-grained formatting changes within the current editing session.
Keyboard drag/reorder: within-parent onlyKeyboard reorder (Space+Arrow) moves a node within its current parent container; cross-container moves require drag-and-drop