Skip to content
Open
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 @@ -536,13 +536,18 @@ 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'
```

**Host-emitted events** (the reserved core list, `CORE_HOOK_EVENTS` in `src/core/plugins/hookBus.ts`): `publish.before`, `publish.after`, `content.entry.created`, `content.entry.updated`, `content.entry.deleted`, `settings.changed`. **Filters**: `publish.html`, `publish.headers`, `content.entry.cells`.

`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 @@ -746,7 +751,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.

`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
2 changes: 1 addition & 1 deletion docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,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
1 change: 1 addition & 0 deletions server/handlers/cms/data/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export async function handleRowPreview(
html: published.html,
pageId: merged.id,
slug: merged.slug,
path: publicPath,
siteId: snapshot.site.id,
cssBundle,
jsModuleIds: published.jsModuleIds.filter((id) => moduleJsMap.has(id)),
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 @@ -639,7 +639,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
50 changes: 35 additions & 15 deletions server/publish/publicRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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 @@ -65,8 +67,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 @@ -129,16 +131,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 @@ -158,12 +165,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 @@ -186,9 +200,15 @@ 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(merged, snapshot, templateContext, ctx)
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 @@ -62,6 +62,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
42 changes: 26 additions & 16 deletions server/publish/republish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
* published pages, without writing a new snapshot. The side-effects — hook
* listeners and filter handlers firing — are the whole point.
*
* Note on the synthetic URL: `renderPublishedSnapshot` accepts an optional
* `url` on its context for per-loop pagination and `{route.*}` binding
* resolution. For background republish (not driven by an inbound HTTP
* request), we pass a synthetic localhost URL so the renderer has a valid
* URL object to work with. The URL is not user-visible and its exact value
* is irrelevant beyond being parseable.
* Background republishes skip template documents, which have no standalone
* public route. Directly routable pages use their public permalink as the
* synthetic URL so route bindings and plugin filter context match a visitor
* render.
*/

import type { DbClient } from '../db/client'
import { buildPageFrame } from '@core/templates/contextFrames'
import { isTemplatePage } from '@core/templates'
import { getPublishedPageSnapshotById } from '../repositories/publish'
import { renderPublishedSnapshot } from './publicRenderer'
import { applyPublishedHtmlPipeline } from './publishedHtmlPipeline'
Expand Down Expand Up @@ -44,10 +44,14 @@ class PageNotPublishedError extends Error {
* fire plugin hook listeners and filters so their side-effects are applied to
* a page that was published before the plugin was activated.
*
* Throws `PageNotPublishedError` if the page is not found or is not
* currently published.
* Returns `false` for a published template document because templates only
* wrap public routes and must not be presented to plugins as standalone
* public pages.
*
* Throws `PageNotPublishedError` if the page is not found or is not currently
* published.
*/
async function republishSinglePage(db: DbClient, pageId: string): Promise<void> {
async function republishSinglePage(db: DbClient, pageId: string): Promise<boolean> {
// Typed read through the publish repository — the snapshot column is parsed
// by the DbClient (`*_json` auto-parse) and typed as `PublishedPageSnapshot`,
// so there is no boundary cast here.
Expand All @@ -56,22 +60,28 @@ async function republishSinglePage(db: DbClient, pageId: string): Promise<void>
throw new PageNotPublishedError(pageId)
}

// Synthetic URL for background republish. The URL object is used by
// renderPublishedSnapshot for pagination helpers and {route.*} bindings.
// Its exact value is irrelevant for background re-renders.
const syntheticUrl = new URL('http://localhost/__republish')
const page = snapshot.site.pages.find((candidate) => candidate.id === snapshot.pageRowId)
if (!page) {
throw new PageNotPublishedError(pageId)
}
if (isTemplatePage(page)) {
return false
}
const syntheticUrl = new URL(buildPageFrame(page).permalink, 'http://localhost')

// Drive the full pipeline (publish.before → frontend.assets injection →
// publish.html filter → publish.after). The returned HTML is discarded —
// the side-effects are what the caller actually needs (lets plugins
// catch up on pages published before they were activated).
const rendered = await renderPublishedSnapshot(snapshot, { db, url: syntheticUrl })
await applyPublishedHtmlPipeline(rendered, db)
return true
}

/**
* Republish every currently-published page. Iterates all published pages and
* calls `republishSinglePage` for each. Returns the total count published.
* Republish every currently-published, directly routable page. Template
* documents are intentionally skipped because they do not have standalone
* public paths. Returns the total count republished.
*
* Errors for individual pages are logged and do not abort the batch — the
* count reflects pages that completed without error.
Expand All @@ -89,7 +99,7 @@ export async function republishAllPages(db: DbClient): Promise<number> {
let count = 0
for (const [i, result] of results.entries()) {
if (result.status === 'fulfilled') {
count++
if (result.value) count++
} else {
console.error(`[publish:republish] republishSinglePage("${rows[i].id}") threw:`, result.reason)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
* Architecture gate: every `applyFilter('publish.html', ...)` call in the
* server must pass a third argument (the context object).
*
* This ensures that plugin filter handlers for `publish.html` always receive
* `{ siteId, pageId, slug }` in their context — without this, plugins that
* destructure those fields would silently receive `undefined`.
* This gate ensures that plugin filter handlers receive a context object.
* Runtime rendering tests verify its `{ siteId, pageId, slug, path }` contract
* and the route-specific values supplied for those fields.
*/

import { describe, expect, it } from 'bun:test'
Expand Down
9 changes: 9 additions & 0 deletions src/__tests__/server/cmsTemplateRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { resetForTests } from '../../../server/publish/renderCache'
import type { PublishedPageSnapshot } from '../../../server/repositories/publish'
import { makePage, makeSite } from '../publisher/helpers'
import { createFakeDb } from './dbTestFake'
import { hookBus } from '../../core/plugins/hookBus'
import {
prepareInactiveSlot,
writeArtefact,
Expand Down Expand Up @@ -37,6 +38,7 @@ describe('CMS dynamic template routes', () => {
// published site can't leak into the next (or in from another test file).
beforeEach(() => {
resetForTests()
hookBus.reset()
})

it('renders a published data row through the highest priority page template', async () => {
Expand Down Expand Up @@ -131,13 +133,20 @@ describe('CMS dynamic template routes', () => {
},
])

hookBus.filter('issue-225-regression', 'publish.html', (html, context) => {
const { path, slug } = context as { path?: string; slug?: string }
return `${html}<meta name="publish-context-path" content="${path ?? ''}"><meta name="publish-context-slug" content="${slug ?? ''}">`
})

const res = await handleServerRequest(new Request('http://localhost/posts/dynamic-post'), { db })
const html = await res.text()

expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/html')
expect(html).toContain('<h1>Dynamic Post</h1>')
expect(html).not.toContain('Static title')
expect(html).toContain('<meta name="publish-context-path" content="/posts/dynamic-post">')
expect(html).toContain('<meta name="publish-context-slug" content="post-template">')
})

it('serves a template route from disk when a baked artefact exists', async () => {
Expand Down
39 changes: 32 additions & 7 deletions src/__tests__/server/publicRendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
renderPublishedDataRowTemplate,
} from '../../../server/publish/publicRenderer'
import type { PublishedDataRow } from '@core/data/schemas'
import { hookBus } from '@core/plugins/hookBus'
import { handleServerRequest } from '../../../server/router'

function snapshot(text: string): PublishedPageSnapshot {
Expand Down Expand Up @@ -111,11 +112,15 @@ describe('public rendering', () => {
// without a reset, one test's cached `/` render would be served to the next.
beforeEach(() => {
resetForTests()
hookBus.reset()
})

it('renders complete HTML from a published snapshot', async () => {
const snap = snapshot('Visible to public')
const { html } = await renderPublishedSnapshot(snap, { db: makeFakeDb(snap) })
const { html } = await renderPublishedSnapshot(snap, {
db: makeFakeDb(snap),
url: new URL('http://localhost/'),
})

expect(html).toContain('<!DOCTYPE html>')
expect(html).toContain('Visible to public')
Expand All @@ -125,11 +130,15 @@ describe('public rendering', () => {
// Guards the page-wrapper's identity reporting after the shared
// `renderMergedTemplate` extraction: pageId/slug come from the page row,
// not the merged tree.
it('reports pageId and slug from the page row for the snapshot path', async () => {
it('reports page identity and the public path for the snapshot path', async () => {
const snap = snapshot('Identity')
const out = await renderPublishedSnapshot(snap, { db: makeFakeDb(snap) })
const out = await renderPublishedSnapshot(snap, {
db: makeFakeDb(snap),
url: new URL('http://localhost/'),
})
expect(out.pageId).toBe('page_home')
expect(out.slug).toBe('index')
expect(out.path).toBe('/')
expect(out.siteId).toBe('project_1')
})

Expand Down Expand Up @@ -161,7 +170,10 @@ describe('public rendering', () => {
publishedAt: '2024-01-01T00:00:00.000Z',
createdAt: '2024-01-01T00:00:00.000Z',
}
const result = await renderPublishedDataRowTemplate(snap, row, { db: makeFakeDb(snap) })
const result = await renderPublishedDataRowTemplate(snap, row, {
db: makeFakeDb(snap),
url: new URL('http://localhost/blog/hello'),
})
expect(result).toBeNull()
})

Expand All @@ -179,20 +191,30 @@ describe('public rendering', () => {
],
}

const { html } = await renderPublishedSnapshot(published, { db: makeFakeDb(published) })
const { html } = await renderPublishedSnapshot(published, {
db: makeFakeDb(published),
url: new URL('http://localhost/'),
})

expect(html).toContain("script-src 'self'")
expect(html).toContain('/_instatic/assets/version_1/entries/entry.js')
})

it('serves / from the active published index snapshot', async () => {
hookBus.filter('home-path-test', 'publish.html', (html, context) => {
const { path, slug } = context as { path?: string; slug?: string }
return `${html}<meta name="publish-context" content="${path ?? ''}|${slug ?? ''}">`
})

const res = await handleServerRequest(new Request('http://localhost/'), {
db: makeFakeDb(snapshot('Homepage')),
})

expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/html')
expect(await res.text()).toContain('Homepage')
const html = await res.text()
expect(html).toContain('Homepage')
expect(html).toContain('<meta name="publish-context" content="/|index">')
})

it('serves immutable published runtime assets by public path', async () => {
Expand Down Expand Up @@ -222,7 +244,10 @@ describe('public rendering', () => {

it('emits external CSS <link> tags pointing at the per-site bundle', async () => {
const snap = snapshot('Hello')
const { html } = await renderPublishedSnapshot(snap, { db: makeFakeDb(snap) })
const { html } = await renderPublishedSnapshot(snap, {
db: makeFakeDb(snap),
url: new URL('http://localhost/'),
})
expect(html).toMatch(/<link rel="stylesheet" href="\/_instatic\/css\/reset-[a-f0-9]{12}\.css">/)
// No inline reset block — site-wide CSS lives in the external bundle.
expect(html).not.toContain(':where(*, *::before, *::after)')
Expand Down
Loading