diff --git a/docs/api.md b/docs/api.md index d881d86b..a0a76282 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/docs/email-block-architecture.md b/docs/email-block-architecture.md index 82032fc7..98842b33 100644 --- a/docs/email-block-architecture.md +++ b/docs/email-block-architecture.md @@ -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 diff --git a/includes/Domain/Email/Token/Token_Preview.php b/includes/Domain/Email/Token/Token_Preview.php new file mode 100644 index 00000000..dda47a5b --- /dev/null +++ b/includes/Domain/Email/Token/Token_Preview.php @@ -0,0 +1,143 @@ + + */ + 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 + */ + 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 ); + } +} diff --git a/includes/REST/Preview_Routes.php b/includes/REST/Preview_Routes.php index da913efe..cc1d318f 100644 --- a/includes/REST/Preview_Routes.php +++ b/includes/REST/Preview_Routes.php @@ -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; @@ -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. * diff --git a/src/scripts/editor/components/EmailPreviewModal.tsx b/src/scripts/editor/components/EmailPreviewModal.tsx index fb133fec..00481e96 100644 --- a/src/scripts/editor/components/EmailPreviewModal.tsx +++ b/src/scripts/editor/components/EmailPreviewModal.tsx @@ -10,6 +10,7 @@ const DESKTOP_WIDTH = 600; const MOBILE_WIDTH = 390; type Viewport = 'desktop' | 'mobile'; +type Personalization = 'sample' | 'tokens'; interface EmailPreviewModalProps { isOpen: boolean; @@ -44,6 +45,8 @@ export default function EmailPreviewModal({ setViewport(device); }; const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); + const [personalization, setPersonalization] = + useState('sample'); const [sourceOpen, setSourceOpen] = useState(false); const isLoading = preview.status === 'loading'; @@ -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 @@ -220,6 +225,41 @@ export default function EmailPreviewModal({ ))} + {hasSample && ( +
+
+ {(['sample', 'tokens'] as const).map(mode => ( + + ))} +
+ + {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' + )} + +
+ )} + {hasWarnings && (