Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/features/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,10 @@ Plugin storage is per-plugin, per-collection. The collection name must match a `

```js
api.cms.hooks.on('publish.after', async (event) => { /* … */ })
api.cms.hooks.filter('publish.html', async (html) => html + '<!-- plugin -->')
api.cms.hooks.filter('publish.html', async (html, { path }) => {
const canonical = new URL(path, 'https://example.com').href
return html.replace('</head>', `<link rel="canonical" href="${canonical}"></head>`)
})
const name = await api.cms.hooks.emit('sync.done', { /* … */ })
// name === 'plugin.<your-plugin-id>.sync.done'
```
Expand All @@ -551,6 +554,8 @@ const name = await api.cms.hooks.emit('sync.done', { /* … */ })

Every filter handler returns the same runtime value type it received. `src/core/plugins/hookBus.ts` checks each result before passing it to the next handler; a mismatched result keeps the previous value and logs the offending plugin ID. For example, `publish.html` returns a string and `content.entry.cells` returns an object, never `null`.

`publish.html` handlers receive `{ pluginId, siteId, pageId, slug, path }`. `slug` identifies the rendered page or template document; for an entry route it remains the entry template's slug. `path` is the emitted page's public URL pathname, so it differs per entry (for example `/posts/hello-world`) and is `/` for the home page.

**Plugin emits are namespaced.** The host rewrites every `emit('<name>', …)` to `plugin.<your-plugin-id>.<name>` (a name already in your own namespace passes through unchanged), so event provenance is unforgeable — a plugin cannot fire `content.entry.created` or any other core event at other listeners, and emitting a name in *another* plugin's namespace (`plugin.<other-id>.*`) is rejected with an error. `emit` resolves to the canonical namespaced name. Cross-plugin eventing still works: subscribing is unrestricted, so a plugin listens to another plugin's events by their full namespaced name, e.g. `api.cms.hooks.on('plugin.acme.analytics.page-view', …)`.

### Loop sources — requires `loops.register`
Expand Down Expand Up @@ -756,7 +761,7 @@ const { count } = await api.cms.content.republishAll()

`tables.create(input)` accepts the plugin-facing field projection, then maps it to the host's canonical `DataField` schema before storage. `richText` fields default to Markdown format, `select` / `multiSelect` option `value`s become stable option IDs, and `relation.targetTableSlug` must resolve to an existing table slug. `repeater` accepts a one-level `fields` schema made from ordinary authorable fields; nested relation slugs are resolved through the same gate, while recursive repeaters, `pageTree`, and `fieldSchema` item fields are rejected by the boundary schema.

`republishAll` fires the full publish pipeline (`publish.before` → `publish.html` → `publish.after`), so other plugins' filters and listeners participate.
`republishAll` fires the full publish pipeline (`publish.before` → `publish.html` → `publish.after`) for directly routable published pages, so other plugins' filters and listeners participate. Template documents are skipped because they have no standalone public path.

Tree mutation and replacement payloads are validated against the canonical `@core/page-tree` TypeBox schemas before host dispatch. `insertNode.node` must be a complete `PageNode`, and `replace(tree)` must receive a complete `NodeTree` with a valid `rootNodeId`, matching node-map keys, resolvable child IDs, and no reachable cycles.

Expand Down
8 changes: 4 additions & 4 deletions docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,17 +386,17 @@ Because `serializeCsp` sorts, the same plugins + adapters always emit a **byte-i
|-------------------------------------------------|---------------------------------------------------------------------|
| `server/publish/publicRouter.ts` | Gateway: Layer A disk fast-path → Layer B LRU → live `resolvePublicRoute` + `renderPublicResolution`. |
| `server/publish/publicRoutes.ts` | Dispatcher tail: `tryServeBranchPreviewLink` (preview cookie in/out), `tryServePublicRoute` (a live preview cookie → `renderBranchPreview`, otherwise `renderPublicResolution`), setup redirect, 404 page. |
| `server/publish/branchPreview.ts` | Render a public URL from a branch's DRAFT for preview-link visitors: same composition as the editor's runtime preview (inline CSS, loops on the branch, on-demand runtime bundles kept in `branchPreviewAssets.ts`, plugin frontend injections, no publish hooks), `no-store` + `noindex`, with a banner. |
| `server/publish/branchPreview.ts` | Render a public URL from a branch's DRAFT for preview-link visitors: same composition as the editor's runtime preview (inline CSS, loops on the branch, on-demand runtime bundles kept in `branchPreviewAssets.ts`, plugin frontend injections), then the same `applyPublishedHtmlPipeline` as a published page, so `publish.before` / `publish.html` / `publish.after` fire (the `publish.html` context `path` is the visitor's public pathname), `no-store` + `noindex`, with a banner. |
| `server/publish/staticArtefact.ts` | Two-slot pointer-file swap (`swapSlot`), per-file atomic writes (`writeArtefact`, `updateArtefactInPlace`), and reads (`readArtefact`). Layer A. |
| `server/publish/renderCache.ts` | In-memory LRU keyed by `(urlPath, canonicalQuery)`, entries versioned. `getOrRender` (single-flight). Reads the version from `publishState`; version captured at render start — a publish landing mid-render discards the result rather than caching stale HTML. Layer B. |
| `server/publish/publishState.ts` | Publish-time process state: `publishVersion` (`bumpPublishVersion`/`getPublishVersion`), `withPublishLock` (ISS-038 publish serializer), and `createVersionedSingleFlight` — the generalized version-keyed single-flight memo the hole endpoint reuses. Repositories import the version + lock from here (not from the cache). |
| `server/publish/holeRuntime.ts` | Exports `runInstaticHoleRuntime` (the TypeScript source of the Layer C runtime) and `HOLE_RUNTIME_JS` (IIFE-serialized string, ~1.1 KB, served to browsers). Tests call `runInstaticHoleRuntime()` directly to avoid dynamic eval. |
| `server/publish/publicRenderer.ts` | `renderPublishedSnapshot`, `renderPublishedDataRowTemplate` — thin wrappers (resolve + compose the template chain, seed the context) over one shared `renderMergedTemplate` (CSS bundle + loop/media prefetch + `publishPage` + publish-version stamping). The entry path also passes the row's `readEntrySeoOverride(...)` through as `documentMeta`. |
| `server/publish/publishedHtmlPipeline.ts` | Post-process: DOMPurify the final HTML, run plugin `publish.html` filter, splice in declarative tags from plugin manifests, inject runtime assets. Runs at publish time only — never per-request. |
| `server/publish/publishedHtmlPipeline.ts` | `applyPublishedHtmlPipeline` — the one post-render pipeline for every HTML-emitting path: emits `publish.before`, splices plugin `frontend.assets[]` tags (`injectFrontendAssets`, with CSP rewrite), stamps CMS form tokens (`stampFormPageTokens`), appends module-JS `<script defer>` tags (`injectModuleScripts`), runs the plugin `publish.html` filter with context `{ siteId, pageId, slug, path }`, then emits `publish.after`. Runs at bake time (`publishSite.ts`, `publishRow.ts`, `bakeDataRows.ts`), on live renders (`publicRouter.ts`), for previews (`branchPreview.ts`, `server/handlers/cms/data/preview.ts`), and in background republish (`republish.ts`). |
| `server/publish/siteCssBundle.ts` | Hash the four CSS strings, write `uploads/css/...` files. The framework bundle's module-CSS half comes from the shared walk in `siteModuleAssets.ts`. |
| `server/publish/siteModuleAssets.ts` | `collectSiteModuleAssets` — the one full-site render walk whose accumulators feed BOTH the framework CSS bundle (`cssMap`) and the published module-JS map (`jsMap`). |
| `server/publish/moduleJsBundle.ts` | Module-JS channel: `buildSiteModuleJsMap` (fresh), `buildPublishedSiteModuleJsMap` (memoised per publishVersion + site, invalidated by `bumpPublishVersion()`), and `injectModuleScripts` (per-page `<script defer>` tags + CSP `script-src 'self'` relaxation). |
| `server/publish/republish.ts` | Bulk re-publish on settings change (touches every page). |
| `server/publish/republish.ts` | `republishAllPages` (plugin API `cms.content.republishAll`): re-renders every directly routable published page through `applyPublishedHtmlPipeline` so plugin hooks and filters fire, discarding the HTML. Skips template documents (no standalone public path). |
| `server/publish/publishScheduler.ts` | Scheduled publish jobs (cron-style). |
| `server/publish/frontendInjections.ts` | Compute plugin `<script>`/`<link>`/`<meta>` tags, preserve per-kind manifest attributes, protect host-owned attributes, and derive CSP entries. |
| `server/publish/mediaPresentation.ts` | Materialize media paths (originals + responsive variants) for publisher consumers. |
Expand Down Expand Up @@ -424,7 +424,7 @@ applyPublishedHtmlPipeline(renderedOutput, db)
├─→ Splice in declarative tags from plugin manifests' `frontend.assets[]`
├─→ Stamp form page tokens onto CMS-native <form> tags (`stampFormPageTokens`)
├─→ Inject per-module published JS: one `<script src="/_instatic/module-js/<id>.js?v=N" defer data-instatic-module-js="<id>">` per moduleId in the page's injection set (render-emitted ∪ hole-subtree ∩ site jsMap), sorted; CSP script-src → 'self' iff ≥ 1 tag
├─→ Run `publish.html` filters in registration order (plugins transform the HTML string)
├─→ Run `publish.html` filters in registration order with `{ siteId, pageId, slug, path }`
├─→ Emit `publish.after` hook
└─→ Return final HTML
```
Expand Down
3 changes: 3 additions & 0 deletions server/handlers/cms/data/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ export async function handleRowPreview(
html: published.html,
pageId: merged.id,
slug: merged.slug,
// The encoded pathname, matching what live, baked and branch-preview
// renders report for the same entry (e.g. `/posts/caf%C3%A9`).
path: syntheticUrl.pathname,
siteId: snapshot.site.id,
jsModuleIds: published.jsModuleIds.filter((id) => moduleJsMap.has(id)),
publishVersion: getPublishVersion(),
Expand Down
2 changes: 1 addition & 1 deletion server/plugins/host/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ export async function handleContentRepublishAll(
_entry: HostPluginRecord,
_db: DbClient,
): Promise<void> {
// `republishAll` operates on the host's full published-pages set —
// `republishAll` operates on the host's directly routable published pages —
// the per-table access check would over-constrain a callee that only
// wants to flush the publish pipeline. The kernel-of-correctness
// remains the `cms.content.publish` permission grant.
Expand Down
4 changes: 2 additions & 2 deletions server/plugins/protocol/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ export interface RunHookFilterRequest {
/**
* Extra context fields forwarded from `hookBus.applyFilter`. Plugin
* handlers receive these merged into `{ pluginId, ...context }`.
* For `publish.html` / `publish.headers` this carries
* `{ siteId, pageId, slug }`.
* For `publish.html` this carries `{ siteId, pageId, slug, path }`;
* `publish.headers` carries `{ siteId, pageId, slug }`.
*/
context?: Record<string, unknown>
}
Expand Down
10 changes: 7 additions & 3 deletions server/publish/branchPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
* Mirrors the editor's own runtime preview rather than the publish path: the
* page (or entry template) is composed from the branch's draft rows, loops
* read the branch, runtime scripts are bundled on demand and served from
* memory, CSS is inlined, and no publish hook fires. Nothing here touches
* the published snapshots, the render caches, or the disk slots — a preview
* is a render, never a publish.
* memory, and CSS is inlined. The HTML then runs the same post-render
* pipeline as a published page (`applyPublishedHtmlPipeline`): the
* `publish.before` / `publish.after` events and the `publish.html` filter
* fire, with `path` set to the visitor's public pathname. Nothing here
* touches the published snapshots, the render caches, or the disk slots —
* a preview is a render, never a publish.
*
* Every response is `no-store` and `noindex`, and carries a banner naming
* the branch with an exit link.
Expand Down Expand Up @@ -236,6 +239,7 @@ export async function renderBranchPreview(
html: rendered.html,
pageId: merged.id,
slug: merged.slug,
path: url.pathname,
siteId: site.id,
jsModuleIds: rendered.jsModuleIds.filter((id) => moduleJsMap.has(id)),
publishVersion: getPublishVersion(),
Expand Down
50 changes: 35 additions & 15 deletions server/publish/publicRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export interface RendererOutput {
/** Identifies what was rendered, for the publish.html filter context. */
pageId: string
slug: string
/** Public URL pathname for this rendered artefact or request. */
path: string
siteId: string
/**
* Sorted moduleIds whose published JS this page must load — already
Expand All @@ -66,8 +68,8 @@ export interface RendererOutput {

interface RenderPublishedSnapshotContext {
db: DbClient
/** Optional request URL — when present, drives per-loop pagination. */
url?: URL
/** Public/request URL — drives route bindings, loop pagination, and plugin context. */
url: URL
/**
* Publish version to stamp into `<instatic-hole data-instatic-version>` placeholders.
* Defaults to the live `getPublishVersion()`. The full/incremental publish
Expand Down Expand Up @@ -133,16 +135,21 @@ export async function renderPublishedSnapshot(
const chain = resolveTemplateChain(snapshot.site, { kind: 'page' })
const merged = composeTemplateChain(chain, { kind: 'page', page })

// Seed route frame from the actual request URL (when available) so
// Seed route frame from the actual request URL so
// `{route.slug}` / `{route.path}` bindings resolve to live values.
// publishPage falls back to the page permalink if no templateContext
// is provided.
const templateContext: TemplateRenderDataContext | undefined = ctx.url
? { entryStack: [], route: buildRouteFrame(ctx.url.toString()) }
: undefined
const templateContext: TemplateRenderDataContext = {
entryStack: [],
route: buildRouteFrame(ctx.url.toString()),
}

const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx)
return { ...rendered, pageId: snapshot.pageRowId, slug: page.slug, siteId: snapshot.site.id }
return {
...rendered,
pageId: snapshot.pageRowId,
slug: page.slug,
path: ctx.url.pathname,
siteId: snapshot.site.id,
}
}

/**
Expand All @@ -162,12 +169,19 @@ export async function renderPublishedNotFound(
const chain = resolveTemplateChain(snapshot.site, { kind: 'page' })
const merged = composeTemplateChain(chain, { kind: 'page', page })

const templateContext: TemplateRenderDataContext | undefined = ctx.url
? { entryStack: [], route: buildRouteFrame(ctx.url.toString()) }
: undefined
const templateContext: TemplateRenderDataContext = {
entryStack: [],
route: buildRouteFrame(ctx.url.toString()),
}

const rendered = await renderMergedTemplate(merged, snapshot, templateContext, ctx)
return { ...rendered, pageId: page.id, slug: page.slug, siteId: snapshot.site.id }
return {
...rendered,
pageId: page.id,
slug: page.slug,
path: ctx.url.pathname,
siteId: snapshot.site.id,
}
}

export async function renderPublishedDataRowTemplate(
Expand All @@ -193,7 +207,7 @@ export async function renderPublishedDataRowTemplate(
// seed. page/site/viewer frames are filled by `publishPage` from the document.
const templateContext: TemplateRenderDataContext = {
entryStack: [publishedDataRowToLoopItem(row)],
...(ctx.url ? { route: buildRouteFrame(ctx.url.toString()) } : {}),
route: buildRouteFrame(ctx.url.toString()),
}

const rendered = await renderMergedTemplate(
Expand All @@ -203,5 +217,11 @@ export async function renderPublishedDataRowTemplate(
ctx,
readEntrySeoOverride(row.cells),
)
return { ...rendered, pageId: merged.id, slug: merged.slug, siteId: snapshot.site.id }
return {
...rendered,
pageId: merged.id,
slug: merged.slug,
path: ctx.url.pathname,
siteId: snapshot.site.id,
}
}
1 change: 1 addition & 0 deletions server/publish/publishedHtmlPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export async function applyPublishedHtmlPipeline(
siteId: rendered.siteId,
pageId: rendered.pageId,
slug: rendered.slug,
path: rendered.path,
})
await hookBus.emit('publish.after', {
siteId: rendered.siteId,
Expand Down
Loading
Loading