From 6fb2c35ff1545c9688cb142129fe61aa1311be5e Mon Sep 17 00:00:00 2001 From: Shawn Mosher <10968229+TheAggressive@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:59:23 -0700 Subject: [PATCH] feat(email): preview provider tokens with labeled sample values (Issue #70 Slice 3) The compiled preview showed provider-resolved tokens as raw {{cb:...}} text. Token_Preview now derives a separate, display-only sample view from a successful artifact using each token's preview behavior: sample tokens show fixed synthetic values (Alex, Sample, reserved example.com links), omit tokens such as subscriber.email are removed, and literal tokens stay canonical. Nothing reads subscriber data, options, or providers. The preview endpoint returns the view as `sample` next to the unchanged canonical html, text, and fingerprint, so preview and download stay byte-identical and review fingerprints are unaffected. The preview modal labels the sample view and offers a toggle back to the tokens; source and download always use the canonical artifact. Co-Authored-By: Claude Opus 5 --- docs/api.md | 2 +- docs/email-block-architecture.md | 17 ++- includes/Domain/Email/Token/Token_Preview.php | 143 ++++++++++++++++++ includes/REST/Preview_Routes.php | 24 +++ .../editor/components/EmailPreviewModal.tsx | 55 ++++++- src/scripts/editor/hooks/useEmailPreview.ts | 6 + src/styles/editor/preview.css | 16 ++ tests/Integration/Preview_Route_Test.php | 31 ++++ tests/Unit/Email/Token/Token_Preview_Test.php | 131 ++++++++++++++++ tests/e2e/preview-personalization.spec.ts | 93 ++++++++++++ tests/js/email-preview-modal.test.tsx | 54 +++++++ tests/js/use-email-preview.test.tsx | 19 +++ 12 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 includes/Domain/Email/Token/Token_Preview.php create mode 100644 tests/Unit/Email/Token/Token_Preview_Test.php create mode 100644 tests/e2e/preview-personalization.spec.ts 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 && (