Search by

contenir / contenir-preview

peptolab

The Contenir preview protocol: signed preview tokens from the CMS, their verification on a site, the request-scoped context a site renders a previewed record from, and shared preview links behind a code.

Package info

github.com/contenir/contenir-preview

pkg:composer/contenir/contenir-preview

Statistics

Installs: 5 177

Dependents: 4

Suggesters: 1

Stars: 0

Open Issues: 0

v0.3.0 2026-10-29 03:14 UTC

This package is auto-updated.

Last update: 2026-11-04 00:45:21 UTC


README

The Contenir preview protocol: the CMS signs a preview token carrying a record and its current values, saved or not, and posts it into a frame on the site; the site verifies the token and renders the page that shows the record with those values in place of the stored ones.

This package is framework-free and holds the parts both sides share. The renderer for each site framework lives in its own package:

The protocol itself is in docs/protocol.md.

Shared configuration

Both sides build the same PreviewConfig: who issues tokens, for which site (both origins, with no path), and the secrets that sign them by key id (at least 32 bytes each).

use Contenir\Preview\PreviewConfig;

$config = new PreviewConfig(
    issuer:   'https://admin.example.com',
    audience: 'https://www.example.com',
    secrets:  ['2026-10' => $secret],
    signWith: '2026-10',
);

On the CMS: signing

use Contenir\Preview\PreviewRecord;
use Contenir\Preview\Token\TokenSigner;

$token = (new TokenSigner($config, $clock))->sign(
    new PreviewRecord('site.page.home', ['resource_id' => '101'], $formValues),
    (string) $userId,
);

Post the token as the contenir_preview field into a frame aimed at $config->endpoint(): the site's origin (the audience) with the protocol's fixed path, /api/resource/preview. Sign a new token each time the preview refreshes: each is accepted once and lives five minutes.

On a site: verifying and rendering

A framework adapter does this for you. Underneath, it is:

use Contenir\Preview\Clock\SystemClock;
use Contenir\Preview\PreviewContext;
use Contenir\Preview\PreviewEntry;
use Contenir\SiteToken\Replay\FileReplayGuard;
use Contenir\Preview\Resolver\PathResolver;
use Contenir\Preview\Resolver\ResolverRegistry;
use Contenir\Preview\Resolver\RouteResolver;
use Contenir\Preview\Token\TokenVerifier;

$clock   = new SystemClock();
$context = new PreviewContext();
$entry   = new PreviewEntry(
    new TokenVerifier($config, $clock, new FileReplayGuard(sys_get_temp_dir(), $clock, 'contenir-preview-')),
    new ResolverRegistry([
        'site.page.home'        => new PathResolver('/{slug}'),
        'library.news.articles' => new RouteResolver('news/article', ['slug' => '{slug}']),
    ]),
    $context,
);

$target = $entry->open($method, $postedToken); // throws InvalidPreview with its HTTP status
// render $target as the site normally would: a path, a named route, or a handler it dispatches

A resolver can also answer with PreviewTarget::handler($controllerOrMiddleware, $params), which the adapter dispatches without routing: the way to preview drafts, records not saved yet, and sites whose routes are built from published records (contenir-workflow ships such a resolver).

Accepted tokens are remembered so each is used once: FileReplayGuard for one server (any existing directory; it touches only its own files), CacheReplayGuard over a shared PSR-16 cache for several. InMemoryReplayGuard lasts one process and suits only long-running servers and tests. The guards, and the signing and checking of the token itself, come from contenir/contenir-site-token, which other protocols between the CMS and a site share; this package adds the record and the preview's own wording for each refusal.

Wherever the site loads a previewable section's records, it lets the context lay the preview's values over them:

$row = $context->overlay('site.page.home', ['resource_id' => $row['resource_id']], $row);

and, for a record not saved yet, $context->unsaved('site.page.home') gives its values.

Every preview answer carries PreviewResponse::headers() (no caching or indexing, framed only by the CMS), and a refusal answers with PreviewResponse::refusal() and the refusal's status.

Shared previews

An editor can send a client a link to one record's draft, behind a six-character code, which opens until the end the editor chose (90 days at most) or until the CMS revokes it (§9 of the protocol). Links live in the site database's preview_share table, created by the migration in schema/ (ConfigProvider::schemaPath(), under ConfigProvider::SCHEMA_NAMESPACE).

On the CMS, Share\Shares makes, lists, re-codes, revokes and prunes them:

use Contenir\Preview\Share\PhpDbShareStore;
use Contenir\Preview\Share\ShareId;
use Contenir\Preview\Share\Shares;

$shares = new Shares(new PhpDbShareStore($siteDatabase), $clock);
$share  = $shares->create('library.projects.article', ['resource_id' => '7'], $ends, 'Jane, Acme', (string) $userId);

ShareId::url($config->audience, $share->id); // https://www.example.com/preview/…
$share->code;                                  // K7M4QX

On a site, a framework adapter hands each visit to /preview/{id} (ShareId::fromPath()) to Share\ShareEntry, and does what its ShareVisit says: render the target, show PreviewResponse::codeForm() or wrongCodeForm(), or set the pass cookie and redirect back. Every answer carries PreviewResponse::shareHeaders(). The record shows as it is saved, through the same preview context, and PreviewRequest::$shared tells a client's visit from an editor's preview. A site turns shared previews on by adding a share block to its contenir_preview configuration.

Container services

ConfigProvider registers the services a renderer needs in any PSR-11 container, built from the application's contenir_preview configuration (Container\PreviewOptions documents its shape): the options, the clock, the replay guard (a PSR-16 cache when one is named, otherwise files), the resolver registry, the token verifier, the entry, the response and the preview context; and, for shared previews, the store, the throttle and the share entry. Each section's resolver is a path pattern (/{slug}), a named route with its parameters, or the name of a PreviewResolverInterface service. Framework adapters merge getDependencies() into their own configuration and add what their framework needs.

PreviewMarker::mark() places the visible "Preview" badge in a page's HTML.

Quality

composer check          # Mago format, lint and analyze, then the tests
composer mutation-test  # Infection