diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md
index d07fa28f4..0964f4c7e 100644
--- a/docs/features/plugin-system.md
+++ b/docs/features/plugin-system.md
@@ -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 + '')
+api.cms.hooks.filter('publish.html', async (html, { path }) => {
+ const canonical = new URL(path, 'https://example.com').href
+ return html.replace('', ``)
+})
const name = await api.cms.hooks.emit('sync.done', { /* … */ })
// name === 'plugin..sync.done'
```
@@ -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('', …)` to `plugin..` (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..*`) 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`
@@ -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.
diff --git a/docs/features/publisher.md b/docs/features/publisher.md
index cf4bc0d37..de7e7512b 100644
--- a/docs/features/publisher.md
+++ b/docs/features/publisher.md
@@ -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 `