Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Email templates use the core `/wp/v2/cb_templates` routes and their `/revisions`

`GET` and `PUT /campaignbridge/v1/brand-kit` read and update the stored email brand colours. `PUT` accepts one slot (`id` and a portable hex `color`). Both require the management capability.

`POST /campaignbridge/v1/preview` compiles unsaved editor content into the canonical email artifact. It accepts `template_id` (integer, required), `content` (string, required, max 512 KB), and optional `metadata` (object with `title`, `language`, `background_color`, `unsubscribe_url`). The response includes `html`, `text`, `diagnostics`, `assets`, `compiler_version`, `profile_version`, and `fingerprint`. A document that fails validation returns diagnostics with HTTP 200 and no HTML. Requires the management capability plus `edit_post` on the target template. Rate-limited.
`POST /campaignbridge/v1/preview` compiles unsaved editor content into the canonical email artifact. It accepts `template_id` (integer, required), `content` (string, required, max 512 KB), and optional `metadata` (object with `title`, `language`, `background_color`, `unsubscribe_url`). The response includes `html`, `text`, `diagnostics`, `assets`, `compiler_version`, `profile_version`, `fingerprint`, and `sample`. `sample` is `null` unless a successful artifact contains provider-resolved tokens; it is then an object with `html` and `text` in which those tokens show fixed synthetic values (or are omitted, per each token's preview behavior). `sample` is display-only: `html`, `text`, and `fingerprint` always describe the canonical artifact with its `{{cb:...}}` tokens. A document that fails validation returns diagnostics with HTTP 200 and no HTML. Requires the management capability plus `edit_post` on the target template. Rate-limited.

All administrative endpoints require the `campaignbridge_manage` capability (defined in `includes/Core/Capabilities.php`). Mutations additionally validate their WordPress nonce. Request arguments use WordPress REST schemas with sanitization and validation callbacks; errors return `WP_Error` with an HTTP status.

Expand Down
17 changes: 15 additions & 2 deletions docs/email-block-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,21 @@ resolved, preserved, nor rewritten; the compile fails closed. Every canonical
token in a successful artifact therefore originates from a
`token_attributes()` attribute.

Not yet implemented: editor token insertion, synthetic preview values,
compliance-token validation, and provider mapping.
The compiled preview can also show synthetic personalization. `Token_Preview`
derives a separate sample view from a successful artifact using each
provider-resolved token's preview behavior: `sample` tokens show a fixed
synthetic value (`Alex`, `Sample`, and reserved `https://example.com/...`
links), `omit` tokens (such as `subscriber.email`) are removed, and `literal`
tokens stay canonical. Sample values are constants; nothing reads subscriber
data, options, or providers. The preview endpoint returns this view as
`sample` next to the unchanged canonical `html`, `text`, and `fingerprint`,
and the preview modal labels it and offers a toggle back to the tokens. Source
view, download, and review always use the canonical artifact.

Not yet implemented: editor token insertion, compliance-token validation, and
provider mapping. CampaignBridge-resolved organization values have no
configured source yet, so a template using them compiles only when a caller
supplies `token_values`.

## Validation and tests

Expand Down
143 changes: 143 additions & 0 deletions includes/Domain/Email/Token/Token_Preview.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php
/**
* Synthetic personalization for compiled email previews.
*
* @package CampaignBridge
* @since 1.0.0
*/

declare(strict_types=1);

namespace CampaignBridge\Domain\Email\Token;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/**
* Renders a clearly labeled sample view of a compiled artifact.
*
* Provider-resolved tokens stay canonical in the artifact, so a preview of the
* artifact alone shows raw `{{cb:...}}` text. This class derives a separate
* sample view from a successful artifact by applying each token's preview
* behavior:
*
* - `sample`: replaced by a fixed synthetic value from SAMPLE_VALUES;
* - `omit`: removed, so no realistic-looking private value is ever shown;
* - `literal`: left as the canonical token.
*
* The artifact itself is never changed: preview, source, download, and review
* fingerprints keep using the canonical output. Sample values are static
* constants; nothing here reads subscriber data, options, or providers.
* Replacement is exact because compiler provenance guarantees every token left
* in a successful artifact is a registered, authored canonical token.
*/
final class Token_Preview {

/**
* Synthetic values for provider-resolved tokens that preview as `sample`.
*
* URLs use the reserved example.com domain so a sample link can never
* reach a real subscriber or provider endpoint.
*
* @var array<string, string>
*/
public const SAMPLE_VALUES = array(
'cb:subscriber.first_name' => 'Alex',
'cb:subscriber.last_name' => 'Sample',
'cb:campaign.view_online_url' => 'https://example.com/campaignbridge-preview/view-online',
'cb:campaign.unsubscribe_url' => 'https://example.com/campaignbridge-preview/unsubscribe',
);

/**
* Canonical expression => plain-text preview value.
*
* @var array<string, string>
*/
private array $replacements = array();

/**
* Build the preview map for a registry.
*
* @param Token_Registry $registry Token vocabulary.
*
* @throws \LogicException When a sample-previewed provider token has no sample.
*/
public function __construct( Token_Registry $registry ) {
foreach ( $registry->all() as $definition ) {
if ( ! $definition->requires_provider_resolution() ) {
continue;
}

$expression = '{{' . $definition->get_id() . '}}';
switch ( $definition->get_preview_behavior() ) {
case Token_Definition::PREVIEW_SAMPLE:
if ( ! isset( self::SAMPLE_VALUES[ $definition->get_id() ] ) ) {
throw new \LogicException( sprintf( 'Token %s previews as a sample but has no sample value.', $definition->get_id() ) );
}
$this->replacements[ $expression ] = self::SAMPLE_VALUES[ $definition->get_id() ];
break;
case Token_Definition::PREVIEW_OMIT:
$this->replacements[ $expression ] = '';
break;
}
}
}

/**
* Create the preview for the default v1 registry.
*
* @return static
*/
public static function default(): self {
return new self( Token_Registry::default() );
}

/**
* Check whether an artifact contains a token this preview replaces.
*
* @param string $artifact Compiled HTML or plain text.
*
* @return bool
*/
public function applies_to( string $artifact ): bool {
foreach ( array_keys( $this->replacements ) as $expression ) {
if ( str_contains( $artifact, $expression ) ) {
return true;
}
}

return false;
}

/**
* Derive the sample view of a compiled HTML artifact.
*
* Values are HTML-escaped, which is correct for both text content and the
* quoted `href` attributes where URL tokens appear.
*
* @param string $html Compiled HTML artifact.
*
* @return string
*/
public function html( string $html ): string {
return strtr(
$html,
array_map(
static fn ( string $value ): string => htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' ),
$this->replacements
)
);
}

/**
* Derive the sample view of a compiled plain-text artifact.
*
* @param string $text Compiled plain-text artifact.
*
* @return string
*/
public function text( string $text ): string {
return strtr( $text, $this->replacements );
}
}
24 changes: 24 additions & 0 deletions includes/REST/Preview_Routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
namespace CampaignBridge\REST;

use CampaignBridge\Core\Storage;
use CampaignBridge\Domain\Email\Compile_Result;
use CampaignBridge\Domain\Email\Token\Token_Preview;
use CampaignBridge\Repository\Brand_Kit_Repository;
use CampaignBridge\Repository\Post_Snapshot_Repository;
use CampaignBridge\Workflow\Email\Template_Preview;
Expand Down Expand Up @@ -126,10 +128,32 @@ public function handle_request( WP_REST_Request $req ): \WP_REST_Response|\WP_Er
'compiler_version' => $result->compiler_version(),
'profile_version' => $result->profile_version(),
'fingerprint' => $result->fingerprint(),
'sample' => $this->sample( $result ),
)
);
}

/**
* Derive the labeled sample-personalization view of a successful artifact.
*
* The canonical `html` and `text` stay unchanged for source, download, and
* review. Null when the compile failed or no token needs a sample value.
*
* @param Compile_Result $result Compile result.
* @return array{html: string, text: string}|null
*/
private function sample( Compile_Result $result ): ?array {
$preview = Token_Preview::default();
if ( ! $result->is_success() || ! $preview->applies_to( $result->html() . $result->text() ) ) {
return null;
}

return array(
'html' => $preview->html( $result->html() ),
'text' => $preview->text( $result->text() ),
);
}

/**
* Build the render metadata for this template.
*
Expand Down
55 changes: 53 additions & 2 deletions src/scripts/editor/components/EmailPreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const DESKTOP_WIDTH = 600;
const MOBILE_WIDTH = 390;

type Viewport = 'desktop' | 'mobile';
type Personalization = 'sample' | 'tokens';

interface EmailPreviewModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -44,6 +45,8 @@ export default function EmailPreviewModal({
setViewport(device);
};
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [personalization, setPersonalization] =
useState<Personalization>('sample');
const [sourceOpen, setSourceOpen] = useState(false);

const isLoading = preview.status === 'loading';
Expand All @@ -52,6 +55,8 @@ export default function EmailPreviewModal({
const hasWarnings = preview.diagnostics.warnings.length > 0;
const hasDiagnostics = hasErrors || hasWarnings;
const isStale = hasEdits && preview.status === 'success' && !isLoading;
const hasSample = preview.status === 'success' && !!preview.sampleHtml;
const showSample = hasSample && personalization === 'sample';
const statusTone = isLoading
? 'loading'
: hasErrors
Expand Down Expand Up @@ -220,6 +225,41 @@ export default function EmailPreviewModal({
))}
</div>

{hasSample && (
<div className='cb-editor__preview-personalization'>
<div
className='cb-editor__preview-toggle'
role='group'
aria-label={__('Personalization', 'campaignbridge')}
>
{(['sample', 'tokens'] as const).map(mode => (
<button
key={mode}
type='button'
className='cb-editor__preview-toggle-option'
aria-pressed={personalization === mode}
onClick={() => setPersonalization(mode)}
>
{mode === 'sample'
? __('Sample values', 'campaignbridge')
: __('Tokens', 'campaignbridge')}
</button>
))}
</div>
<small className='cb-editor__preview-personalization-note'>
{showSample
? __(
'Sample values stand in for each subscriber’s data. Source and download keep the tokens.',
'campaignbridge'
)
: __(
'Tokens are replaced with each subscriber’s data when the email is sent.',
'campaignbridge'
)}
</small>
</div>
)}

{hasWarnings && (
<Button
variant='tertiary'
Expand Down Expand Up @@ -371,13 +411,24 @@ export default function EmailPreviewModal({
)}
<div className='cb-editor__preview-email'>
<EmailPreviewFrame
html={preview.html}
html={
showSample
? (preview.sampleHtml ?? preview.html)
: preview.html
}
width={
viewport === 'mobile'
? MOBILE_WIDTH
: (preview.width ?? DESKTOP_WIDTH)
}
title={__('Email preview', 'campaignbridge')}
title={
showSample
? __(
'Email preview with sample personalization',
'campaignbridge'
)
: __('Email preview', 'campaignbridge')
}
/>
</div>
</>
Expand Down
6 changes: 6 additions & 0 deletions src/scripts/editor/hooks/useEmailPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const PREVIEW_PATH = '/campaignbridge/v1/preview';

interface EmailPreviewResponse {
html: string;
/** Display-only view with synthetic personalization; null when unused. */
sample?: { html: string; text: string } | null;
diagnostics: Array<{
severity: string;
code: string;
Expand All @@ -26,7 +28,10 @@ export type PreviewStatus = 'idle' | 'loading' | 'success' | 'error';

export interface EmailPreview {
status: PreviewStatus;
/** Canonical compiled artifact, with personalization tokens intact. */
html: string;
/** Same artifact with synthetic sample values, for display only. */
sampleHtml?: string | null;
diagnostics: {
errors: Array<{ code: string; message: string }>;
warnings: Array<{ code: string; message: string }>;
Expand Down Expand Up @@ -124,6 +129,7 @@ export function useEmailPreview(
content: serializedContent,
title,
html: response.html,
sampleHtml: response.sample?.html ?? null,
diagnostics: {
errors: (response.diagnostics ?? []).filter(
d => d.severity === 'error'
Expand Down
16 changes: 16 additions & 0 deletions src/styles/editor/preview.css
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,22 @@
fill: currentColor;
}

.cb-editor__preview-personalization {
align-items: center;
display: inline-flex;
flex-wrap: wrap;
gap: 4px 10px;
}

.cb-editor__preview-personalization .cb-editor__preview-toggle-option {
padding: 0 12px;
}

.cb-editor__preview-personalization-note {
color: #536175;
max-width: 44ch;
}

.cb-editor__preview-toggle {
display: inline-flex;
border: 1px solid #d0d9e5;
Expand Down
Loading
Loading