Skip to content

Commit a0ca227

Browse files
DavidBabinecclaude
andcommitted
feat: harden plugin SDK + split host modules for production
Three-tranche plugin SDK hardening pass, plus a parallel refactor that splits the monolithic plugin host modules into focused submodules. Net diff: +11.6k / -7k LOC across 207 files; ~2.8k LOC net reduction in plugin examples thanks to the new shared primitives and APIs replacing bespoke workaround code. WHY: the original "build 5 plugins to find SDK gaps" strategy worked. Each plugin (Forms / SEO / Newsletter / Analytics / Search) hit a gap that landed in this PR — every fix here was triggered by a real plugin breaking against a real SDK shortcoming. TRANCHE 1 — SDK completeness - api.cms.pages.list() / republish(id) / republishAll() — plugins can enumerate + re-render pages from inside the QuickJS sandbox. Two new permissions (cms.pages.read, cms.pages.publish) synced across the 4-place convention (types union + capabilities matrix + builder aliases + permissions docs). - publish.html filter context extended from string to (html, { pluginId, siteId, pageId, slug }). Eliminates the Map<siteId, pageId> bridges SEO Suite + Search had to ship to associate HTML with its page. - ID pattern split: MANIFEST_SLUG_PATTERN (strict kebab, URL-bound) for resource + admin-page IDs; RESOURCE_FIELD_ID_PATTERN (camelCase allowed, JSON-key-bound) for field IDs. Two categories, two patterns. - Republish primitives in server/publish/republish.ts reuse the existing renderPublishedSnapshot pipeline — no duplicate render code. TRANCHE 2 — Plugin author DX - Tabs primitive (compound API, generic on value type, ARIA tablist, arrow-key nav) in src/ui/components/Tabs/. Six plugins refactored to drop ~150 LOC of bespoke tab shells. - jsonField() helper in server/db/jsonExtract.ts — dialect-aware JSON extraction with branded JsonFieldExpr type, allowlisted in db-postgres-isms.test.ts plus a NEW bidirectional egress gate (json-extract-egress.test.ts) preventing the patterns from spreading. - cms.storage.collection().list({ filter, orderBy, limit, offset }) with { records, totalCount } envelope. Full operator suite (eq/ne/gt/gte/lt/lte/in/like). TypeBox schemas in storageSchemas.ts as source of truth. Six plugins refactored: server-side filters replace JS-side filter loops. BOOT RESILIENCE - InstalledPluginResult discriminated union applied to BOTH read AND write repository paths. Boot loop now isolates per-phase failures (manifest-validation / module-pack-load / server-entrypoint-load / activate) — one broken plugin can no longer crash the entire CMS. - Admin UI surfaces error-state plugins with Reinstall + Remove affordances; Settings / Schedules / Enable hidden when manifest is unparseable. 409 Conflict for PATCH/restart on broken plugins. - frontendInjections filters broken plugins so they can't pollute published pages with stale injection plans. CSP RELAXATION FIX - relaxCspForPlan() previously only relaxed script-src for INLINE scripts; external scripts only relaxed worker-src. Tracker plugins with a single external <script src=…> tag had their tag injected but blocked by script-src 'none'. Fixed: external scripts now also relax script-src to 'self' (same-origin, where plugin bundles live). Gated by 4 new tests covering all four CSP relaxation tiers. PARALLEL REFACTOR — host module split - server/plugins/quickjsHost.ts → server/plugins/quickjs/{...} - server/plugins/pluginWorkerHost.ts → server/plugins/host/{...} - server/plugins/workerProtocol.ts → server/plugins/protocol/{...} - server/publish/dataRenderer.ts → folded into publishedHtmlPipeline.ts - server/handlers/cms/tracker.ts → replaced by the declarative frontend.assets[] surface - BlockPicker → BlockLibrary (dock-style picker with persistent height) - Various editor-store slice splits USER PREFERENCES (already shipped in prior commit, refined here) - Server-backed dashboard layout persistence per user via user_preferences (user_id, key, value_json) table. Hook is optimistic + debounced + flushes on unmount. - Repository functions renamed to *Row suffix for consistency with other repos (readMediaAssetRow pattern). NEW HOST-UI PRIMITIVES (cumulative across all tranches) - Widget, WidgetList, RangeTabs, Tabs / TabList / Tab / TabPanel, Sparkline, Bars, StackedBar, StatValue, Delta — all exposed via @pagebuilder/host-ui, gated by plugin-host-ui-runtime-parity.test. ARCHITECTURE INVARIANTS ADDED - plugin-cms-pages-surface.test.ts - publish-html-filter-context.test.ts - no-plugin-tab-shells.test.ts - storage-list-envelope.test.ts - json-extract-egress.test.ts - plugin-boot-resilience.test.ts - frontendInjections.test.ts (4 CSP relaxation tiers) - dispatcher-html-pipeline.test.ts - centralized-site-mutation-history.test.ts - visual-components-mutation-contract.test.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 38d083b commit a0ca227

207 files changed

Lines changed: 11588 additions & 7041 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/plugins/authoring.md

Lines changed: 203 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,12 @@ Upload the resulting zip from the Plugins admin page.
6060
"entrypoints": {
6161
"server": "server/index.js",
6262
"editor": "editor/index.js",
63-
"modules": "modules/index.js",
64-
"frontend": "frontend/tracker.js"
63+
"modules": "modules/index.js"
64+
},
65+
"frontend": {
66+
"assets": [
67+
{ "kind": "script", "src": "frontend/tracker.js", "placement": "body-end", "strategy": "defer" }
68+
]
6569
},
6670
"resources": [],
6771
"adminPages": [],
@@ -73,6 +77,20 @@ Plugin IDs must be namespaced, such as `acme.workflow`. Versions must be semver-
7377

7478
`apiVersion: 1` is the only currently supported value.
7579

80+
### Manifest IDs
81+
82+
IDs in `plugin.json` follow two different rules depending on their role:
83+
84+
**Resource IDs and admin page IDs** become URL path segments. They must be lowercase kebab-case:
85+
- `subscribers`, `seo-entries`, `contact-forms`
86+
- `Subscribers`, `seoEntries`, `My Posts` ✗ (uppercase, camelCase, spaces)
87+
88+
**Resource field IDs** are JSON object keys only — not URL segments. They accept any common JavaScript identifier convention (camelCase, snake_case, kebab-case):
89+
- `email`, `subscribedAt`, `page_id`, `first-name`
90+
- `My Field`, `123bad`, `has space` ✗ (spaces, leading digit, spaces)
91+
92+
The manifest validator produces a clear error message if you violate either rule. When in doubt, run `bun pb-plugin lint` before uploading.
93+
7694
### Entrypoints
7795

7896
| Field | Required permission | Loaded by | Use it for |
@@ -81,7 +99,7 @@ Plugin IDs must be namespaced, such as `acme.workflow`. Versions must be semver-
8199
| `editor` | `editor.commands` / `editor.toolbar` etc. | Editor mount | Toolbar buttons, commands, store transactions |
82100
| `admin` | `admin.navigation` | Admin app pages | Custom admin app rendered into a plugin admin page |
83101
| `modules` | `modules.register` | Editor mount + server boot | Adding new modules to the canvas library |
84-
| `frontend` | `frontend.scripts` (+ `frontend.tracker` if posting events) | Published pages | Analytics, custom widgets, A/B testing |
102+
| `frontend/*.ts` | `frontend.assets` | Published pages | Analytics trackers, custom widgets, A/B testing, polyfills |
85103

86104
### Pack
87105

@@ -133,8 +151,8 @@ export function activate(api) {
133151
const url = new URL(ctx.req.url)
134152
const format = url.searchParams.get('format') ?? 'csv'
135153

136-
const rows = await api.cms.storage.collection('events').list()
137-
const csv = rows.map(r => [r.id, r.data.name].join(',')).join('\r\n')
154+
const { records } = await api.cms.storage.collection('events').list()
155+
const csv = records.map(r => [r.id, r.data.name].join(',')).join('\r\n')
138156

139157
return {
140158
__response: true,
@@ -183,14 +201,42 @@ See [sandbox.md](sandbox.md#network-access) for allowlist semantics and the `fet
183201

184202
## Plugin Storage
185203

186-
Declare resources in the manifest, then use `cms.storage`:
204+
Declare resources in the manifest, then use `cms.storage`. `list()` always returns an envelope `{ records, totalCount }` — destructure before accessing the array:
187205

188-
```js
206+
```ts
189207
const items = api.cms.storage.collection('items')
190208
await items.create({ title: 'Draft', status: 'pending' })
191-
const records = await items.list()
209+
210+
// Bare list — simple case
211+
const { records } = await items.list()
212+
213+
// With filter, pagination, and sorting
214+
const { records, totalCount } = await api.cms.storage.collection('subscribers').list({
215+
filter: {
216+
status: 'active', // shorthand: equals
217+
createdAt: { gte: '2026-01-01' }, // ISO 8601 — strings compare lexicographically
218+
email: { like: '%@example.com' }, // case-insensitive LIKE
219+
},
220+
orderBy: { createdAt: 'desc' },
221+
limit: 25,
222+
offset: 0,
223+
})
192224
```
193225

226+
**Filter operators:** `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `in` / `like`. A bare value (string, number, boolean, null) is shorthand for `eq`.
227+
228+
**`like`** is case-insensitive and uses SQL `LIKE` semantics (`%` = any characters, `_` = single character).
229+
230+
**Field names** must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/` — use camelCase or snake_case identifiers. Kebab-case (`form-id`) is not valid in `filter` / `orderBy`; use `formId` instead.
231+
232+
**`limit`** defaults to 100, maximum 1000. **`offset`** defaults to 0.
233+
234+
**`totalCount`** is the full count of matching records before pagination — useful for building paginated UIs.
235+
236+
**Filter is AND-only.** There is no OR combinator across fields. If you need OR across two fields, run two queries.
237+
238+
> **Note:** the `storage-list-envelope` architecture gate enforces the destructure pattern and will fail the build if you chain array methods directly on the `.list()` return value.
239+
194240
## Admin Apps
195241

196242
Admin app pages use manifest content kind `app` and default-export a real React component. Plugin authors write JSX, import React directly, and pull design-system primitives from `@pagebuilder/host-ui`:
@@ -240,6 +286,11 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
240286
import {
241287
Alert, Button, Card, Checkbox, Code, EmptyState, Heading,
242288
Input, SearchBar, Select, Separator, Stack, Switch, Text, Textarea,
289+
// Tab compound component (ARIA-correct, keyboard-navigable)
290+
Tabs, TabList, Tab, TabPanel,
291+
// Data-visualisation + widget primitives
292+
Sparkline, Bars, StackedBar, StatValue, Delta, RangeTabs,
293+
Widget, WidgetList, WidgetListRow,
243294
} from '@pagebuilder/host-ui'
244295

245296
// Editor / settings / route helpers — real React hooks
@@ -263,6 +314,36 @@ import {
263314
} from '@pagebuilder/plugin-sdk'
264315
```
265316

317+
### Tabs
318+
319+
Use the `Tabs` compound component whenever your admin app needs tabbed navigation. It is ARIA-correct (`tablist` / `tab` / `tabpanel` roles), keyboard-navigable (ArrowLeft / ArrowRight / Home / End with automatic activation), and generic over the value type.
320+
321+
```tsx
322+
import { useState } from 'react'
323+
import { Tabs, TabList, Tab, TabPanel } from '@pagebuilder/host-ui'
324+
325+
function Dashboard() {
326+
const [active, setActive] = useState<'subscribers' | 'lists'>('subscribers')
327+
return (
328+
<Tabs<'subscribers' | 'lists'> value={active} onChange={setActive}>
329+
<TabList ariaLabel="Newsletter sections">
330+
<Tab value="subscribers">Subscribers</Tab>
331+
<Tab value="lists">Lists</Tab>
332+
</TabList>
333+
<TabPanel value="subscribers"><SubscribersTable /></TabPanel>
334+
<TabPanel value="lists"><ListsTable /></TabPanel>
335+
</Tabs>
336+
)
337+
}
338+
```
339+
340+
- **ARIA:** renders `role="tablist"` on `TabList`, `role="tab"` on each `Tab`, and `role="tabpanel"` on each `TabPanel` — correct labelling out of the box.
341+
- **Keyboard:** arrow keys move focus and change the active tab simultaneously (automatic activation). Home / End jump to first / last tab.
342+
- **Generic value type:** the `TValue extends string` type parameter propagates through `Tabs → Tab → TabPanel`, so TypeScript catches mismatched `value` props at compile time.
343+
- **Style:** underline-indicator. Use `RangeTabs` for compact inline widget-header toggles (pill / segmented-control style).
344+
345+
Do **not** roll your own `role="tablist"` div — it will fail the `no-plugin-tab-shells` architecture gate.
346+
266347
### What the plugin's component receives
267348

268349
Admin app components get a `page` prop:
@@ -720,30 +801,76 @@ export default ({ pluginId }) => [
720801

721802
Same `render(props, children)` runs on the publisher (server) and inside the editor canvas preview, so the markup you ship is exactly what visitors see.
722803

723-
## Frontend Tracker (`frontend.scripts` + `frontend.tracker`)
804+
## Frontend Assets (`frontend.assets`)
724805

725-
> **Important — frontend scripts are NOT a React surface.** Published pages don't load the editor, the host's React, or the import map. A `frontend.scripts` bundle that imports `react` or `@pagebuilder/host-ui` will crash the visitor's browser at runtime. Use vanilla JS, the DOM API, and `window.__pb` for analytics or widget code. If you genuinely need a frontend React widget, bundle React yourself — but most use cases (analytics, click tracking, A/B testing) don't need it. `pb-plugin build` enforces this by NOT externalizing host packages for frontend bundles, so a stray `import` becomes a build-time bundling cost (your React copy ships per visitor) rather than a runtime resolution failure.
806+
> **Important — frontend scripts are NOT a React surface.** Published pages don't load the editor, the host's React, or the import map. A frontend bundle that imports `react` or `@pagebuilder/host-ui` will crash the visitor's browser at runtime. Use vanilla JS and the DOM API. If you genuinely need a frontend React widget, bundle React yourself — but most use cases (analytics, click tracking, A/B testing) don't need it. `pb-plugin build` enforces this by NOT externalizing host packages for frontend bundles, so a stray `import` becomes a build-time bundling cost (your React copy ships per visitor) rather than a runtime resolution failure.
726807
727-
The host injects a tiny tracker runtime into every published page when any installed plugin has `frontend.scripts` or `frontend.tracker` granted. The runtime exposes `window.__pb`:
808+
Plugins declare frontend assets in their `pb-plugin.config.ts`. The host splices them into every published page at four placement anchors (`head`, `head-end`, `body-start`, `body-end`), rewrites the CSP based on what the plan needs, and runs the `publish.html` filter — but ships **no tag content of its own**. The host is purely substrate. Plugins that want shared frontend state (`window.__pb_analytics`, `window.__pb_chat`, …) ship the IIFE that installs it as one of their own assets.
728809

729810
```ts
730-
window.__pb.visitorId // stable per-browser id
731-
window.__pb.sessionId // stable per-session id
732-
window.__pb.tracker.send(name, payload) // implicit pluginId
733-
window.__pb.tracker.sendFor(pluginId, name, payload) // explicit
734-
window.__pb.hooks.on(name, listener) // page-view, link-click, scroll-depth, ...
735-
window.__pb.hooks.emit(name, detail)
811+
// pb-plugin.config.ts
812+
export default definePlugin({
813+
//
814+
permissions: [permissions.frontendAssets, permissions.cmsRoutes /* for ingestion */],
815+
frontend: {
816+
assets: [
817+
// External JS file shipped under `frontend/tracker.{ts,tsx}` and
818+
// bundled to `dist/frontend/tracker.js` by `pb-plugin build`.
819+
{ kind: 'script', src: 'frontend/tracker.js', placement: 'body-end', strategy: 'defer' },
820+
821+
// Inline <script> — short bootstrap snippets that can't wait for fetch.
822+
{ kind: 'script-inline', content: `console.log('booted')`, placement: 'body-end' },
823+
824+
// External CSS.
825+
{ kind: 'style', href: 'frontend/widget.css', placement: 'head-end' },
826+
827+
// Inline <style>.
828+
{ kind: 'style-inline', content: `.foo { color: red }`, placement: 'head-end' },
829+
830+
// <link> — preconnect, dns-prefetch, preload, alternate, etc.
831+
{ kind: 'link', attrs: { rel: 'preconnect', href: 'https://cdn.example.com' } },
832+
833+
// <meta>.
834+
{ kind: 'meta', attrs: { name: 'theme-color', content: '#000000' } },
835+
],
836+
},
837+
})
736838
```
737839

738-
Server-side, plugins listen with `api.cms.hooks.on('tracker.event', ...)` and persist into their own resource via `api.cms.storage.collection(...)`.
840+
`script` and `style` `src`/`href` paths are resolved against the plugin's `assetBasePath`. The build CLI auto-discovers every `.ts`/`.tsx` file directly under `frontend/` and bundles it to `dist/frontend/<name>.js` — reference the built `.js` path from your manifest.
739841

740-
```js
741-
// frontend/tracker.js
742-
window.__pb.hooks.on('page-view', (detail) => {
743-
window.__pb.tracker.sendFor('acme.showcase', 'page-view', detail)
842+
### Ingesting events from the frontend
843+
844+
The host has no built-in tracker channel. To receive events from your frontend bundle, register a public route on the server side and POST to it directly:
845+
846+
```ts
847+
// server/index.ts
848+
api.cms.routes.postPublic('/ingest', async (ctx) => {
849+
const body = ctx.body as Record<string, unknown>
850+
await api.cms.storage.collection('events').create({
851+
name: String(body.eventName ?? ''),
852+
payload: JSON.stringify(body.payload ?? {}),
853+
'received-at': new Date().toISOString(),
854+
})
855+
return { ok: true }
744856
})
745857
```
746858

859+
```ts
860+
// frontend/tracker.ts
861+
const PLUGIN_ID = 'acme.analytics'
862+
const ROUTE = `/admin/api/cms/plugins/${PLUGIN_ID}/runtime/ingest`
863+
864+
fetch(ROUTE, {
865+
method: 'POST',
866+
headers: { 'Content-Type': 'application/json' },
867+
keepalive: true,
868+
body: JSON.stringify({ eventName: 'page-view', payload: { path: location.pathname } }),
869+
}).catch(() => { /* fire-and-forget */ })
870+
```
871+
872+
Plugins that want cross-plugin coordination can additionally emit on the hook bus (`api.cms.hooks.emit('acme.analytics.page-view', payload)`) and other plugins listen with `api.cms.hooks.on('acme.analytics.page-view', ...)`.
873+
747874
## Loop Sources (`loops.register`)
748875

749876
```js
@@ -771,18 +898,67 @@ Built-in events:
771898
| --- | --- |
772899
| `publish.before` | `{ siteId, pageId? }` |
773900
| `publish.after` | `{ siteId, pageId? }` |
774-
| `tracker.event` | `{ pluginId, eventName, payload, visitorId, sessionId, pagePath, referrer, receivedAt }` |
775901
| `content.entry.created/updated/deleted` | `{ collectionId, entryId }` |
776902

777903
Built-in filters:
778904

779-
| Filter | Type |
780-
| --- | --- |
781-
| `publish.html` | `string` (full HTML before sending to browser) |
782-
| `publish.headers` | `Record<string, string>` |
905+
| Filter | Type | Context |
906+
| --- | --- | --- |
907+
| `publish.html` | `string` (full HTML before sending to browser) | `{ siteId, pageId, slug }` |
908+
| `publish.headers` | `Record<string, string>` | `{ siteId, pageId, slug }` |
909+
910+
Filter handlers receive the current value as the first argument and a context object as the second. The context always includes `pluginId`; named filters like `publish.html` add extra fields:
911+
912+
```js
913+
api.cms.hooks.filter('publish.html', (html, { siteId, pageId, slug }) => {
914+
return html.replace('</body>', `<!-- page:${slug} siteId:${siteId} -->\n</body>`)
915+
})
916+
```
783917

784918
Plugins can `emit` and `on` any event. If you publish a documented event under your namespace, prefix it with `plugin.<your-id>.`.
785919

920+
## Page Enumeration and Republish (`cms.pages.*`)
921+
922+
Plugins can list published pages and trigger a republish via `api.cms.pages`. This is useful when a plugin's filter or hook needs to be applied to pages that were already published before the plugin was activated.
923+
924+
### Listing pages
925+
926+
Requires `cms.pages.read`:
927+
928+
```js
929+
export async function activate(api) {
930+
const pages = await api.cms.pages.list()
931+
// pages: [{ id, slug, title, lastPublishedAt }]
932+
for (const page of pages) {
933+
api.plugin.log(`Page: ${page.slug} (published: ${page.lastPublishedAt})`)
934+
}
935+
}
936+
```
937+
938+
### Republishing a single page
939+
940+
Requires `cms.pages.publish`. Re-runs the full publish pipeline (publish.before → publish.html filter → publish.after) for the page:
941+
942+
```js
943+
export async function activate(api) {
944+
// Re-apply this plugin's publish.html filter to an existing page
945+
await api.cms.pages.republish('some-page-id')
946+
}
947+
```
948+
949+
Throws if the page is not currently published.
950+
951+
### Republishing all pages
952+
953+
Requires `cms.pages.publish`. Iterates all published pages and runs the pipeline for each. Returns the total count:
954+
955+
```js
956+
export async function activate(api) {
957+
const { count } = await api.cms.pages.republishAll()
958+
api.plugin.log(`Republished ${count} pages`)
959+
}
960+
```
961+
786962
## Type Declarations
787963

788964
The SDK types ship inline with the repo at `src/core/plugin-sdk/`. When developing a plugin inside the monorepo, `pb-plugin.config.ts` imports `definePlugin` directly from there:

docs/plugins/permissions.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ Plugins declare requested permissions in `plugin.json`. The CMS shows those perm
3434
| `modules.register` | Editor, manifest | High | Ship new modules that show up in the canvas module library. |
3535
| `loops.register` | Editor, server, manifest | Medium | Register new entity sources for the `base.loop` module. |
3636
| `visualComponents.register` | Admin, manifest | Medium | Ship Visual Components, page templates, and class packs that get imported into the user's site on activation. |
37-
| `frontend.scripts` | Frontend, manifest | High | Inject a JavaScript file into every published page (analytics, third-party widgets, custom runtimes). |
38-
| `frontend.tracker` | Frontend, server, manifest | Medium | Receive structured tracker events from published pages and store them in plugin-owned storage. |
37+
| `frontend.assets` | Frontend, manifest | High | Inject declarative tags (external/inline scripts, external/inline styles, `<link>`, `<meta>`) into every published page via the manifest's `frontend.assets[]` array. The host is purely the substrate: it splices tags at four placement anchors (`head`, `head-end`, `body-start`, `body-end`), rewrites the CSP based on what the plan contains, and runs the `publish.html` filter — but ships no tag content of its own. Plugins that want shared frontend state (`window.__pb_analytics`, etc.) ship the IIFE that installs it as one of their own assets. |
3938
| `network.outbound` | Server | High | Make outbound HTTP requests from the plugin's sandboxed server entrypoint. Requires a `networkAllowedHosts` allowlist in the manifest; calls to hosts outside the list are rejected at the host bridge even when the permission is granted. See [`sandbox.md`](sandbox.md#network-access). |
39+
| `cms.pages.read` | Server, CMS | Low | Enumerate all published pages on the site via `api.cms.pages.list()`. Read-only — does not grant republish capability. |
40+
| `cms.pages.publish` | Server, CMS | Medium | Trigger a republish of one or all published pages via `api.cms.pages.republish(id)` / `api.cms.pages.republishAll()`. The full publish pipeline fires (publish.before → publish.html → publish.after), so hook listeners and filters registered by other plugins run as part of the chain. |
4041
| `cms.schedule` | Server | High | Register handlers that fire on a cadence (`hourly`, `daily`, `weekly`, `monthly`, `every-N-minutes`). Each handler runs inside the QuickJS sandbox with a per-fire wall-clock budget; the host's scheduler tick drives dispatch and records run history. See [`scheduled-jobs.md`](scheduled-jobs.md). |
4142
| `unstable.internals` | Admin, editor, server | Dangerous | Reserved for trusted first-party plugins that need unstable host internals. |
4243

docs/plugins/sandbox.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,15 @@ Use the SDK (api.cms.storage.*, api.cms.hooks.*, api.cms.routes.*) for I/O inste
183183

184184
This catches honest mistakes before anything ships. The same scan runs again at install time on the host so unsigned or hand-zipped packages can't slip through.
185185

186+
## Page republish — host-side execution
187+
188+
`api.cms.pages.republish(pageId)` and `api.cms.pages.republishAll()` execute **outside the VM** — the host runs the full publish pipeline (publish.before → publish.html filter → publish.after) directly in the main process. From the plugin's perspective, the call is just an async `__hostCall` that resolves when the republish chain completes.
189+
190+
This means:
191+
- Hook listeners and filter handlers registered by **all** active plugins fire during the republish, not just those of the calling plugin.
192+
- The host, not the VM, drives the pipeline. The plugin's sandbox cannot observe or intercept the HTML pipeline internally — it only receives or transforms the value when its own registered filter runs.
193+
- Large republish batches (`republishAll` on a site with many pages) are synchronous from the plugin's await perspective. Budget the time accordingly. Using `api.cms.schedule` for batch republish tasks (instead of a one-shot `activate` call) is recommended for production plugins.
194+
186195
## Cross-context signaling
187196

188197
When you need to send a value from inside the sandbox out to host code (for tests, dashboards, observers, other plugins), use **hooks**:
784 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)