From c201028bfcf040c62212be319d8e9891f1a0749d Mon Sep 17 00:00:00 2001 From: EuanTop Date: Tue, 28 Jul 2026 14:26:19 +0800 Subject: [PATCH 1/4] fix(publish): expose public path to publish filters --- docs/features/plugin-system.md | 7 ++- docs/features/publisher.md | 2 +- server/handlers/cms/data/preview.ts | 1 + server/plugins/protocol/messages.ts | 2 +- server/publish/publicRenderer.ts | 50 +++++++++++++------ server/publish/publishedHtmlPipeline.ts | 1 + server/publish/republish.ts | 18 +++---- .../publish-html-filter-context.test.ts | 2 +- .../server/cmsTemplateRoutes.test.ts | 9 ++++ src/__tests__/server/publicRendering.test.ts | 39 ++++++++++++--- src/core/plugin-sdk/types/hooks.ts | 17 +++++-- src/core/plugins/hookBus.ts | 2 +- 12 files changed, 109 insertions(+), 41 deletions(-) diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index d07fa28f4..b99f7d0b0 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` and `publish.headers` 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` diff --git a/docs/features/publisher.md b/docs/features/publisher.md index cf4bc0d37..3f4dee5a6 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -424,7 +424,7 @@ applyPublishedHtmlPipeline(renderedOutput, db) ├─→ Splice in declarative tags from plugin manifests' `frontend.assets[]` ├─→ Stamp form page tokens onto CMS-native
tags (`stampFormPageTokens`) ├─→ Inject per-module published JS: one `