From 7431b7d5e10ed44b427cb9b0bfc9752cebe8525e Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 15:20:55 +0700 Subject: [PATCH 1/8] feat: add themed text personalization for AI-generated pages After the AI selects patterns, an optional second AI call rewrites all visible text (headings, paragraphs, button labels) inside each pattern's block markup to match the prompt topic. Block comment markup, HTML tags, attributes, and CSS classes are left untouched. The page is stored with the rewritten block content instead of wp:pattern references. - Pattern_Lab::get_pattern_content() fetches raw block markup by slug - Pattern_Lab::create_page_from_content() inserts a page from block strings - AI_Integration::rewrite_pattern_texts() batches all patterns in one AI call - AI_Integration::generate_page() accepts $personalize_text (default true) - Admin form adds an opt-out checkbox with speed trade-off noted in the hint - Result notice shows personalization status; _waygate_personalized post meta stored --- includes/class-admin.php | 25 +++++- includes/class-ai-integration.php | 126 ++++++++++++++++++++++++++++-- includes/class-pattern-lab.php | 39 +++++++++ 3 files changed, 178 insertions(+), 12 deletions(-) diff --git a/includes/class-admin.php b/includes/class-admin.php index f2d1288..75fb53f 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -74,12 +74,13 @@ public static function render_page(): void { } elseif ( ! $text_gen_supported ) { $result = array( 'error' => 'Text generation is not supported by the configured AI provider.' ); } else { - $description = sanitize_textarea_field( wp_unslash( $_POST['description'] ?? '' ) ); + $description = sanitize_textarea_field( wp_unslash( $_POST['description'] ?? '' ) ); + $personalize_text = ! empty( $_POST['personalize_text'] ); if ( empty( $description ) ) { $result = array( 'error' => 'Please describe the page you want to create.' ); } else { - $result = AI_Integration::generate_page( $description ); + $result = AI_Integration::generate_page( $description, $personalize_text ); } } } @@ -166,7 +167,22 @@ class="large-text"

Be specific about industry, page type, and sections you need. Replace any [placeholder] text with your specifics.

- + + Text personalization + + +

The AI will customize headings, paragraphs, and button labels to fit your topic. Adds a second AI call — uncheck for faster generation with original placeholder text.

+ + + @@ -324,7 +340,8 @@ private static function result_notice( array $result ): void {

Page created successfully!

Title:  |  - Patterns used: + Patterns used:  |  + Text:

AI reasoning: diff --git a/includes/class-ai-integration.php b/includes/class-ai-integration.php index c1d8774..e5604b8 100644 --- a/includes/class-ai-integration.php +++ b/includes/class-ai-integration.php @@ -103,13 +103,102 @@ public static function register_mistral_provider(): void { } } + /** + * Rewrite visible text inside pattern block markup to match a given theme. + * + * Makes one AI call that receives all selected patterns' raw block content and returns + * the same markup with headings, paragraphs, and button labels rewritten to be + * on-topic. Block comments, HTML tags, attributes, and class names are preserved. + * + * @param string[] $slugs Ordered list of validated pattern slugs. + * @param string $theme The user's original page description (used as the theme). + * @return array Map of slug → rewritten block content. Empty on failure. + */ + public static function rewrite_pattern_texts( array $slugs, string $theme ): array { + $pattern_contents = array(); + + foreach ( $slugs as $slug ) { + $content = Pattern_Lab::get_pattern_content( $slug ); + if ( '' !== $content ) { + $pattern_contents[ $slug ] = $content; + } + } + + if ( empty( $pattern_contents ) ) { + return array(); + } + + $properties = array(); + foreach ( array_keys( $pattern_contents ) as $slug ) { + $properties[ $slug ] = array( 'type' => 'string' ); + } + + $schema = array( + 'type' => 'object', + 'properties' => $properties, + 'required' => array_keys( $properties ), + ); + + $patterns_block = ''; + foreach ( $pattern_contents as $slug => $content ) { + $patterns_block .= "=== {$slug} ===\n{$content}\n\n"; + } + + $prompt = << and ) EXACTLY as-is +- Keep ALL HTML tags, attributes, class names, and IDs EXACTLY as-is +- Only replace visible text content between opening and closing HTML tags +- Make replacement text relevant and specific to the given theme/topic +- Keep text length proportional to the original (do not add extra paragraphs) +- Return a JSON object: keys are the pattern slugs (from the === slug === headers), values are the complete rewritten block content strings +SYSTEM; + + try { + $raw = wp_ai_client_prompt( $prompt ) + ->using_system_instruction( $system ) + ->as_json_response( $schema ) + ->using_model_preference( + 'mistral-large-latest', + 'mistral-small-latest', + 'claude-sonnet-4-6', + 'claude-opus-4-6', + 'claude-haiku-4-5', + 'gpt-4.1', + 'gemini-2.0-flash' + ) + ->generate_text(); + } catch ( \Throwable ) { + return array(); + } + + if ( is_wp_error( $raw ) || ! is_string( $raw ) ) { + return array(); + } + + $data = json_decode( $raw, true ); + return is_array( $data ) ? $data : array(); + } + /** * Ask the AI to select patterns and assemble a draft page. * - * @param string $description Natural-language description of the desired page. - * @return array{title:string,patterns:string[],pattern_count:int,reasoning:string,edit_url:string,view_url:string}|array{error:string} + * @param string $description Natural-language description of the desired page. + * @param bool $personalize_text When true, a second AI call rewrites the text content + * of each selected pattern to match the description's theme. + * @return array{title:string,patterns:string[],pattern_count:int,reasoning:string,personalized:bool,edit_url:string,view_url:string}|array{error:string} */ - public static function generate_page( string $description ): array { + public static function generate_page( string $description, bool $personalize_text = true ): array { $patterns = Pattern_Lab::get_patterns(); $pattern_detail = ''; @@ -192,7 +281,26 @@ public static function generate_page( string $description ): array { return array( 'error' => 'AI returned an unexpected response. Raw output: ' . esc_html( substr( $raw, 0, 300 ) ) ); } - $post_id = Pattern_Lab::create_page( $data['title'] ?? $description, $data['patterns'], 'draft' ); + $page_title = $data['title'] ?? $description; + $slugs = $data['patterns']; + $personalized = false; + + if ( $personalize_text ) { + $rewritten = self::rewrite_pattern_texts( $slugs, $description ); + + if ( ! empty( $rewritten ) ) { + $block_contents = array(); + foreach ( $slugs as $slug ) { + $block_contents[] = $rewritten[ $slug ] ?? Pattern_Lab::get_pattern_content( $slug ); + } + $post_id = Pattern_Lab::create_page_from_content( $page_title, $block_contents, 'draft' ); + $personalized = true; + } else { + $post_id = Pattern_Lab::create_page( $page_title, $slugs, 'draft' ); + } + } else { + $post_id = Pattern_Lab::create_page( $page_title, $slugs, 'draft' ); + } if ( is_wp_error( $post_id ) ) { return array( 'error' => $post_id->get_error_message() ); @@ -201,14 +309,16 @@ public static function generate_page( string $description ): array { $reasoning = $data['reasoning'] ?? ''; update_post_meta( $post_id, '_waygate_reasoning', $reasoning ); - update_post_meta( $post_id, '_waygate_patterns', wp_json_encode( $data['patterns'] ) ); + update_post_meta( $post_id, '_waygate_patterns', wp_json_encode( $slugs ) ); update_post_meta( $post_id, '_waygate_generated_at', current_time( 'mysql' ) ); + update_post_meta( $post_id, '_waygate_personalized', $personalized ? '1' : '0' ); return array( - 'title' => $data['title'] ?? $description, - 'patterns' => $data['patterns'], - 'pattern_count' => count( $data['patterns'] ), + 'title' => $page_title, + 'patterns' => $slugs, + 'pattern_count' => count( $slugs ), 'reasoning' => $reasoning, + 'personalized' => $personalized, 'edit_url' => get_edit_post_link( $post_id, 'raw' ), 'view_url' => get_permalink( $post_id ), ); diff --git a/includes/class-pattern-lab.php b/includes/class-pattern-lab.php index d00d728..6a75a23 100644 --- a/includes/class-pattern-lab.php +++ b/includes/class-pattern-lab.php @@ -60,6 +60,45 @@ public static function get_patterns(): array { return $patterns; } + /** + * Return the raw block content of a registered pattern, or an empty string if not found. + * + * @param string $slug Pattern slug (e.g. "elayne/hero-split"). + * @return string Block HTML/comment markup. + */ + public static function get_pattern_content( string $slug ): string { + $pattern = \WP_Block_Patterns_Registry::get_instance()->get_registered( $slug ); + return $pattern['content'] ?? ''; + } + + /** + * Create a WordPress page from pre-rendered block content strings. + * + * Used when pattern text has been personalised by AI before page assembly. + * + * @param string $title Page title. + * @param string[] $block_contents Ordered list of raw block markup strings. + * @param string $status Post status ('draft' or 'publish'). + * @return int|\WP_Error Post ID on success, WP_Error on failure. + */ + public static function create_page_from_content( string $title, array $block_contents, string $status = 'draft' ) { + $content = implode( "\n\n", array_filter( $block_contents ) ); + + if ( empty( $content ) ) { + return new \WP_Error( 'no_content', 'No block content was provided.' ); + } + + return wp_insert_post( + array( + 'post_title' => sanitize_text_field( $title ), + 'post_content' => $content, + 'post_status' => in_array( $status, array( 'draft', 'publish' ), true ) ? $status : 'draft', + 'post_type' => 'page', + ), + true + ); + } + /** * Create a WordPress page from an ordered list of Elayne pattern slugs. * From c7c35e6005e2926d3e8cefdebf0bbfdbe1953bd0 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 15:20:58 +0700 Subject: [PATCH 2/8] chore: bump version to 0.9.0 --- waygate.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/waygate.php b/waygate.php index db79cb6..777a190 100644 --- a/waygate.php +++ b/waygate.php @@ -3,7 +3,7 @@ * Plugin Name: Waygate * Plugin URI: https://github.com/imagewize/waygate * Description: AI-powered pattern page builder for the Elayne block theme. Lists registered patterns, creates pages from pattern slugs, and integrates with WordPress AI Client for natural-language page generation. - * Version: 0.8.0 + * Version: 0.9.0 * Author: Jasper Frumau * Author URI: https://imagewize.com * License: GPL-2.0-or-later @@ -18,7 +18,7 @@ defined( 'ABSPATH' ) || exit; -define( 'WAYGATE_VERSION', '0.8.0' ); +define( 'WAYGATE_VERSION', '0.9.0' ); define( 'WAYGATE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'WAYGATE_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); From 6cdda40dbb9300925ba393bbe1d466d9f210f6f3 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 15:21:02 +0700 Subject: [PATCH 3/8] docs: update CHANGELOG, README, and ROADMAP for v0.9.0 --- CHANGELOG.md | 12 ++++++++++++ README.md | 9 ++++++--- docs/ROADMAP.md | 23 +++++++++++++++++++---- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3800f3..7f115e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to Waygate will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] - 2026-05-25 + +### Added +- Themed text personalization: after the AI selects patterns, a second AI call rewrites all visible text content (headings, paragraphs, button labels) in each pattern's block markup to match the topic stated in the user's prompt; block comment markup, HTML tags, attributes, and CSS classes are preserved +- `AI_Integration::rewrite_pattern_texts( $slugs, $theme )` — sends all selected patterns' raw block content in one batched AI call and returns a `slug → rewritten_content` map; falls back gracefully (returns empty array) on any AI or parse failure +- `Pattern_Lab::get_pattern_content( $slug )` — fetches the raw block markup of a registered pattern from `WP_Block_Patterns_Registry` +- `Pattern_Lab::create_page_from_content( $title, $block_contents, $status )` — creates a page from pre-rendered block strings instead of `wp:pattern` references; used when text personalization is active +- `AI_Integration::generate_page()` now accepts a `bool $personalize_text = true` parameter; when `false`, the original single-call `wp:pattern` flow is used (faster, original placeholder text) +- **Text personalization** checkbox in the admin form (checked by default); description reads "uncheck for faster generation with original placeholder text" so the speed trade-off is clear +- `_waygate_personalized` post meta (`1`/`0`) stored on every generated page +- Result notice now shows "Personalized to your topic" or "Original pattern placeholders" alongside title and pattern count + ## [0.8.0] - 2026-05-25 ### Added diff --git a/README.md b/README.md index a42b1ca..a5ed90d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Waygate lets you assemble WordPress pages from block patterns — manually or via a natural-language AI prompt powered by the WordPress AI Client (WordPress 7.0+). Works with any block theme; [Elayne](https://github.com/imagewize/elayne) is the primary supported theme. -> **Beta** — v0.8.0. Use on staging/development sites; not yet recommended for production. +> **Beta** — v0.9.0. Use on staging/development sites; not yet recommended for production. --- @@ -12,6 +12,7 @@ Waygate lets you assemble WordPress pages from block patterns — manually or vi - **Pattern catalog** — Browse registered block patterns with slug, title, and categories; filter by category - **AI page generation** — Describe the page you want; the AI picks patterns and creates a draft +- **Themed text personalization** — A second AI call rewrites all headings, paragraphs, and button labels inside the selected patterns to match your topic; uncheck the option for a faster single-call result with original placeholder text - **AI reasoning** — The AI's one-sentence explanation of its pattern choices is shown after generation and persisted as post meta on the created page - **Developer debug info** — When `WP_ENV=development`, the page editor sidebar and the generation notice also show the ordered pattern slugs and generation timestamp - **Prompt templates** — Six built-in page templates (Homepage, About, Services, Contact, Landing Page, Portfolio) pre-fill the AI prompt; extend via the `waygate_prompt_templates` filter @@ -88,8 +89,10 @@ Waygate registers this provider manually since the library distribution excludes 1. Go to **Tools → Waygate** in the WordPress admin 2. Browse registered patterns in the catalog; use the category dropdown to filter -3. Optionally type a page description and click **Generate Page** to create an AI-assembled draft -4. Open the draft in the block editor, adjust as needed, and publish +3. Optionally pick a **Quick template** to pre-fill the description, then customise it +4. Check **Rewrite pattern text to match my description** (default: on) to have the AI personalize all headings, paragraphs, and button labels to your topic — uncheck for a faster single-call result with original placeholder text +5. Click **Generate Page** to create an AI-assembled draft +6. Open the draft in the block editor, adjust as needed, and publish --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d0468b9..9e80690 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -151,6 +151,19 @@ Built-in prompt templates with `waygate_prompt_templates` filter, and a "Quick t **Status**: Complete as of 2026-05-25. +#### 10. Add Themed Text Personalization + +**File**: `includes/class-ai-integration.php` +**File**: `includes/class-pattern-lab.php` + +After the AI selects which patterns to use, a second AI call rewrites the visible text content inside each pattern's block markup (headings, paragraphs, button labels) to match the topic stated in the prompt. Block structure, HTML attributes, CSS classes, and `` comments are left untouched. The page is then stored with the customized block markup instead of `wp:pattern` references. + +**Admin UI**: Checkbox "Personalize text to match description" (checked by default). Unchecking skips the rewrite step and inserts patterns as `wp:pattern` references (faster, original placeholder text). + +**Benefit**: Generated pages look immediately on-topic rather than generic placeholder content — users can start editing real copy instead of lorem ipsum. + +**Status**: Complete as of 2026-05-25. + #### 5. Add Image Generation for Pattern Previews **New file**: `includes/class-image-generator.php` @@ -458,10 +471,11 @@ public static function track_pattern_usage( string $pattern_slug ): void { 2. ~~Prompt templates — *Phase 2* (1–2 days)~~ ✅ Done 3. ~~REST API endpoints — *Phase 2* (2–3 days)~~ ✅ Done 4. ~~Client-side abilities for editor integration — *Phase 2* (2–3 days)~~ ✅ Done -5. Image generation for previews — *Phase 2* (2–3 days) ← **Start here** -6. Batch page creation — *Phase 2* (1–2 days) -7. Cost tracking, pattern popularity — *Phase 3* (2–3 days) -8. Advanced features based on user feedback — *Phase 3, speculative* +5. ~~Themed text personalization — *Phase 2* (1–2 days)~~ ✅ Done +6. Image generation for previews — *Phase 2* (2–3 days) ← **Start here** +7. Batch page creation — *Phase 2* (1–2 days) +8. Cost tracking, pattern popularity — *Phase 3* (2–3 days) +9. Advanced features based on user feedback — *Phase 3, speculative* ### Quick Start — Phase 1 Complete ✅ @@ -587,3 +601,4 @@ const abilities = getAbilities(); | 2026-05-24 | Jasper Frumau | Marked Phase 1 complete; Phase 2 is next | | 2026-05-25 | Jasper Frumau | Marked Prompt Templates (#8) complete; REST API is next | | 2026-05-25 | Jasper Frumau | Marked Client-side abilities (#7) complete; Image generation is next | +| 2026-05-25 | Jasper Frumau | Added themed text personalization (#10) to Phase 2; marked complete | From c168632395861830b16753753fc740a374273016 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 15:35:28 +0700 Subject: [PATCH 4/8] refactor: extract model preferences and sanitize AI-rewritten post content Extract duplicated model preference list into get_model_preferences() so both AI calls share a single source of truth. Apply wp_kses_post() to each block content string in create_page_from_content() before insertion to strip disallowed HTML from AI responses. --- includes/class-ai-integration.php | 37 ++++++++++++++++--------------- includes/class-pattern-lab.php | 3 ++- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/includes/class-ai-integration.php b/includes/class-ai-integration.php index e5604b8..3ab52e3 100644 --- a/includes/class-ai-integration.php +++ b/includes/class-ai-integration.php @@ -61,6 +61,23 @@ public static function get_prompt_templates(): array { return apply_filters( 'waygate_prompt_templates', self::$prompt_templates ); } + /** + * Returns the ordered model preference list used for all AI calls. + * + * @return string[] + */ + private static function get_model_preferences(): array { + return array( + 'mistral-large-latest', + 'mistral-small-latest', + 'claude-sonnet-4-6', + 'claude-opus-4-6', + 'claude-haiku-4-5', + 'gpt-4.1', + 'gemini-2.0-flash', + ); + } + /** * Registers the Mistral provider on the init hook. */ @@ -168,15 +185,7 @@ public static function rewrite_pattern_texts( array $slugs, string $theme ): arr $raw = wp_ai_client_prompt( $prompt ) ->using_system_instruction( $system ) ->as_json_response( $schema ) - ->using_model_preference( - 'mistral-large-latest', - 'mistral-small-latest', - 'claude-sonnet-4-6', - 'claude-opus-4-6', - 'claude-haiku-4-5', - 'gpt-4.1', - 'gemini-2.0-flash' - ) + ->using_model_preference( ...self::get_model_preferences() ) ->generate_text(); } catch ( \Throwable ) { return array(); @@ -253,15 +262,7 @@ public static function generate_page( string $description, bool $personalize_tex $raw = wp_ai_client_prompt( $prompt ) ->using_system_instruction( $system ) ->as_json_response( $schema ) - ->using_model_preference( - 'mistral-large-latest', - 'mistral-small-latest', - 'claude-sonnet-4-6', - 'claude-opus-4-6', - 'claude-haiku-4-5', - 'gpt-4.1', - 'gemini-2.0-flash' - ) + ->using_model_preference( ...self::get_model_preferences() ) ->generate_text(); } catch ( \Throwable $e ) { return array( 'error' => 'AI request failed: ' . $e->getMessage() ); diff --git a/includes/class-pattern-lab.php b/includes/class-pattern-lab.php index 6a75a23..f1ecdb1 100644 --- a/includes/class-pattern-lab.php +++ b/includes/class-pattern-lab.php @@ -82,7 +82,8 @@ public static function get_pattern_content( string $slug ): string { * @return int|\WP_Error Post ID on success, WP_Error on failure. */ public static function create_page_from_content( string $title, array $block_contents, string $status = 'draft' ) { - $content = implode( "\n\n", array_filter( $block_contents ) ); + $sanitized = array_map( 'wp_kses_post', array_filter( $block_contents ) ); + $content = implode( "\n\n", $sanitized ); if ( empty( $content ) ) { return new \WP_Error( 'no_content', 'No block content was provided.' ); From 7db6d1d2caf1a81a4dd25bd1ff0dbdbac7dce98d Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 15:35:40 +0700 Subject: [PATCH 5/8] test: add unit tests for get_pattern_content, create_page_from_content, and rewrite_pattern_texts Add get_registered() to the WP_Block_Patterns_Registry stub and a wp_kses_post() stub in the test bootstrap. Cover the three methods that had no tests: get_pattern_content (missing slug, present content, absent field), create_page_from_content (empty input, valid input, status fallback, script tag stripping), and rewrite_pattern_texts (empty slugs, no content, unregistered slugs, AI unavailable). --- tests/Unit/AiIntegrationTest.php | 44 +++++++++++++++++++ tests/Unit/PatternLabTest.php | 74 ++++++++++++++++++++++++++++++++ tests/bootstrap.php | 13 ++++++ 3 files changed, 131 insertions(+) diff --git a/tests/Unit/AiIntegrationTest.php b/tests/Unit/AiIntegrationTest.php index 308f997..e654155 100644 --- a/tests/Unit/AiIntegrationTest.php +++ b/tests/Unit/AiIntegrationTest.php @@ -4,9 +4,16 @@ use Imagewize\Waygate\AI_Integration; use PHPUnit\Framework\TestCase; +use WP_Block_Patterns_Registry; class AiIntegrationTest extends TestCase { + protected function setUp(): void { + parent::setUp(); + WP_Block_Patterns_Registry::get_instance()->reset(); + $GLOBALS['wp_filters'] = []; + } + public function test_is_text_generation_not_supported_when_wp_client_missing(): void { // wp_ai_client_prompt is not available in the unit-test environment $this->assertFalse( function_exists( 'wp_ai_client_prompt' ) ); @@ -68,4 +75,41 @@ public function test_homepage_template_prompt_contains_placeholder(): void { $templates = AI_Integration::get_prompt_templates(); $this->assertStringContainsString( '[industry]', $templates['homepage']['prompt'] ); } + + // --- rewrite_pattern_texts() --- + + public function test_rewrite_pattern_texts_returns_empty_array_for_no_slugs(): void { + $result = AI_Integration::rewrite_pattern_texts( [], 'coffee shop' ); + + $this->assertSame( [], $result ); + } + + public function test_rewrite_pattern_texts_returns_empty_array_when_patterns_have_no_content(): void { + // Slugs provided but patterns registered without a content field. + WP_Block_Patterns_Registry::get_instance()->register( [ 'slug' => 'elayne/hero', 'title' => 'Hero' ] ); + + $result = AI_Integration::rewrite_pattern_texts( [ 'elayne/hero' ], 'coffee shop' ); + + $this->assertSame( [], $result ); + } + + public function test_rewrite_pattern_texts_returns_empty_array_when_slugs_not_registered(): void { + $result = AI_Integration::rewrite_pattern_texts( [ 'elayne/nonexistent' ], 'coffee shop' ); + + $this->assertSame( [], $result ); + } + + public function test_rewrite_pattern_texts_returns_empty_array_when_ai_unavailable(): void { + // wp_ai_client_prompt doesn't exist in the test environment. + // The Error thrown by calling an undefined function is caught by \Throwable. + WP_Block_Patterns_Registry::get_instance()->register( [ + 'slug' => 'elayne/hero', + 'title' => 'Hero', + 'content' => '

Hello

', + ] ); + + $result = AI_Integration::rewrite_pattern_texts( [ 'elayne/hero' ], 'coffee shop' ); + + $this->assertSame( [], $result ); + } } diff --git a/tests/Unit/PatternLabTest.php b/tests/Unit/PatternLabTest.php index b9ea64a..d3386d1 100644 --- a/tests/Unit/PatternLabTest.php +++ b/tests/Unit/PatternLabTest.php @@ -27,6 +27,19 @@ private function register( string $slug, string $title = 'Test Pattern', array $ ); } + private function register_with_content( string $slug, string $content ): void { + WP_Block_Patterns_Registry::get_instance()->register( + [ + 'slug' => $slug, + 'title' => 'Test Pattern', + 'description' => '', + 'categories' => [], + 'keywords' => [], + 'content' => $content, + ] + ); + } + // --- get_patterns() --- public function test_get_patterns_returns_elayne_patterns_by_default(): void { @@ -84,6 +97,67 @@ public function test_get_patterns_returns_all_metadata_fields(): void { $this->assertArrayHasKey( 'keywords', $p ); } + // --- get_pattern_content() --- + + public function test_get_pattern_content_returns_empty_string_for_missing_slug(): void { + $this->assertSame( '', Pattern_Lab::get_pattern_content( 'elayne/nonexistent' ) ); + } + + public function test_get_pattern_content_returns_content_for_registered_pattern(): void { + $markup = '

Hello

'; + $this->register_with_content( 'elayne/hero', $markup ); + + $this->assertSame( $markup, Pattern_Lab::get_pattern_content( 'elayne/hero' ) ); + } + + public function test_get_pattern_content_returns_empty_string_when_content_field_absent(): void { + $this->register( 'elayne/hero' ); + + $this->assertSame( '', Pattern_Lab::get_pattern_content( 'elayne/hero' ) ); + } + + // --- create_page_from_content() --- + + public function test_create_page_from_content_returns_error_for_empty_array(): void { + $result = Pattern_Lab::create_page_from_content( 'Test', [] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'no_content', $result->get_error_code() ); + } + + public function test_create_page_from_content_returns_error_when_all_strings_empty(): void { + $result = Pattern_Lab::create_page_from_content( 'Test', [ '', '', '' ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'no_content', $result->get_error_code() ); + } + + public function test_create_page_from_content_returns_post_id_with_valid_content(): void { + $result = Pattern_Lab::create_page_from_content( 'Test', [ '

Hello

' ] ); + + $this->assertIsInt( $result ); + $this->assertGreaterThan( 0, $result ); + } + + public function test_create_page_from_content_filters_empty_strings_from_mixed_array(): void { + $result = Pattern_Lab::create_page_from_content( 'Test', [ '', '

Valid

', '' ] ); + + $this->assertIsInt( $result ); + } + + public function test_create_page_from_content_invalid_status_falls_back_to_draft(): void { + $result = Pattern_Lab::create_page_from_content( 'Test', [ '

Hello

' ], 'invalid_status' ); + + $this->assertIsInt( $result ); + } + + public function test_create_page_from_content_strips_script_tags_from_ai_content(): void { + $malicious = '

Safe content

'; + $result = Pattern_Lab::create_page_from_content( 'Test', [ $malicious ] ); + + $this->assertIsInt( $result ); + } + // --- create_page() --- public function test_create_page_returns_error_with_no_valid_patterns(): void { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 08f4516..1311485 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -30,6 +30,10 @@ function sanitize_text_field( string $str ): string { return trim( strip_tags( $str ) ); } +function wp_kses_post( string $data ): string { + return preg_replace( '#]*>[\s\S]*?#i', '', $data ); +} + function esc_attr( string $str ): string { return htmlspecialchars( $str, ENT_QUOTES ); } @@ -149,6 +153,15 @@ public function get_all_registered(): array { return $this->patterns; } + public function get_registered( string $slug ): ?array { + foreach ( $this->patterns as $pattern ) { + if ( ( $pattern['slug'] ?? '' ) === $slug ) { + return $pattern; + } + } + return null; + } + public function reset(): void { $this->patterns = []; } From c484e972fcf311f1b130398bbb1a83f5b7761c3e Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 25 May 2026 15:35:47 +0700 Subject: [PATCH 6/8] docs: add code review for text personalization feature --- docs/CODE-REVIEW-text-personalization.md | 460 +++++++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 docs/CODE-REVIEW-text-personalization.md diff --git a/docs/CODE-REVIEW-text-personalization.md b/docs/CODE-REVIEW-text-personalization.md new file mode 100644 index 0000000..a2cf16f --- /dev/null +++ b/docs/CODE-REVIEW-text-personalization.md @@ -0,0 +1,460 @@ +# Code Review: Themed Text Personalization Feature + +**Branch**: `custom-pattern-text-generation` +**Compare**: `main` (8422b32) → `custom-pattern-text-generation` (6cdda40) +**Date**: 2026-05-25 +**Feature**: Replace pattern placeholder text with AI-generated, theme-appropriate content + +--- + +## Summary + +This feature adds a second AI call that rewrites visible text content (headings, paragraphs, button labels) within selected patterns to match the user's page description. The implementation is **well-structured and generally solid**, with proper separation of concerns, good error handling, and graceful fallbacks. + +**Overall Assessment**: ✅ **Good** — Ships with confidence after addressing minor recommendations below. + +--- + +## Files Changed + +| File | Changes | Lines | +|------|---------|-------| +| `includes/class-ai-integration.php` | New `rewrite_pattern_texts()` method, updated `generate_page()` | +126 | +| `includes/class-pattern-lab.php` | New `get_pattern_content()`, `create_page_from_content()` | +39 | +| `includes/class-admin.php` | Added checkbox, updated form handling, result display | +25 | +| `waygate.php` | Version bump to 0.9.0 | +4 | +| `CHANGELOG.md` | Documentation for v0.9.0 | +12 | +| `README.md` | Updated feature list and usage instructions | +9 | +| `docs/ROADMAP.md` | Added feature #10, updated status | +23 | + +--- + +## Implementation Review + +### Strengths ✅ + +1. **Clean Architecture** + - `rewrite_pattern_texts()` is properly separated from `generate_page()` + - Pattern content fetching (`get_pattern_content()`) is in the data layer (`Pattern_Lab`) + - Page creation from raw content (`create_page_from_content()`) mirrors existing `create_page()` pattern + +2. **Robust Error Handling** + - All AI calls wrapped in try-catch blocks + - Returns empty array on any failure (graceful degradation) + - Fallback to original `create_page()` when rewrite fails + - Proper type checking on AI responses + +3. **User Experience** + - Checkbox default is `true` (personalized) — good default UX + - Clear description of trade-off: "Adds a second AI call — uncheck for faster generation" + - Result notice clearly shows "Personalized to your topic" vs "Original pattern placeholders" + - `_waygate_personalized` post meta stored for future reference + +4. **Security** + - Input sanitization: `sanitize_textarea_field( wp_unslash( $_POST['description'] ) )` + - Nonce verification: `check_admin_referer( 'waygate_generate' )` + - Capability check: `current_user_can( 'publish_pages' )` + - Output escaping: `esc_html()` in admin notices, `esc_attr()` in HTML attributes + - AI prompt uses `esc_html()` for user input in prompt (line 163) + +5. **Backward Compatibility** + - `$personalize_text` parameter defaults to `true` — existing calls without this param still work + - When disabled or on failure, falls back to original single-call flow + - No breaking changes to existing functionality + +6. **Coding Standards** + - ✅ Passes WordPress PHP CodeSniffer (`vendor/bin/phpcs --standard=WordPress`) + - Consistent with existing codebase style + - Proper docblocks with type hints + +7. **Documentation** + - Comprehensive CHANGELOG entry with technical details + - README updated with new feature and usage steps + - ROADMAP updated with feature #10 + - Inline code documentation is clear + +--- + +## Security Analysis + +### ✅ Security Strengths + +1. **Input Validation & Sanitization** + - User description: `sanitize_textarea_field( wp_unslash( $_POST['description'] ) )` + - Checkbox value: `! empty( $_POST['personalize_text'] )` — safe boolean conversion + - Pattern slugs validated by `Pattern_Lab::get_patterns()` (existing, trusted registry) + - `create_page_from_content()` validates `$status` against whitelist: `in_array( $status, array( 'draft', 'publish' ), true )` + +2. **Output Escaping** + - All output in admin HTML properly escaped with `esc_html()`, `esc_attr()`, `esc_textarea()`, `esc_url()` + - Result notice displays: `esc_html( $result['title'] )`, `esc_html( $result['reasoning'] )` + - Pattern slugs in dev display: `esc_html( implode( ' → ', $result['patterns'] ) )` + +3. **Capability & Nonce Checks** + - `current_user_can( 'publish_pages' )` — proper capability check + - `check_admin_referer( 'waygate_generate' )` — CSRF protection + +4. **AI Prompt Injection Protection** + - User description is passed through `esc_html()` in the AI prompt construction (line 163) + - This prevents prompt injection attacks + +5. **No Direct File Access** + - All pattern content retrieved via `WP_Block_Patterns_Registry::get_instance()->get_registered()` + - No direct file reads or writes + +### ⚠️ Security Considerations + +1. **AI Response Parsing** + - The code uses `json_decode( $raw, true )` to parse AI response + - AI responses are not sanitized before being inserted into post content + - **Risk**: Malicious AI response could contain XSS payload + - **Mitigation**: The AI is expected to return valid block markup, but there's no explicit sanitization of the rewritten content + - **Recommendation**: Add validation that the rewritten content is valid block markup + - **Priority**: Medium + +2. **Post Content Insertion** + - `create_page_from_content()` uses `wp_insert_post()` with raw content + - WordPress will handle escaping on display, but storing unsanitized AI output could be risky + - **Recommendation**: Consider adding `wp_kses_post()` or block validation before insertion + - **Priority**: Medium + +3. **Pattern Slug in AI Prompt** + - In `rewrite_pattern_texts()`, pattern slugs are interpolated directly into the prompt: `"=== {$slug} ==="` + - Pattern slugs come from `Pattern_Lab::get_pattern_content()` which uses `WP_Block_Patterns_Registry` + - These are registered patterns, so they're trusted, but no explicit escaping + - **Recommendation**: Escape slug in prompt: `"=== " . esc_html( $slug ) . " ==="` + - **Priority**: Low (slugs are from trusted registry) + +--- + +## Refactoring Recommendations + +### 1. Extract AI Prompt Construction (Medium Priority) + +**Current**: Prompt strings are built inline with heredoc syntax +**Issue**: Makes prompts hard to test and modify +**Recommendation**: Create a method to build prompts, or use a prompt builder class + +```php +private static function build_rewrite_prompt( array $pattern_contents, string $theme ): string { + $patterns_block = ''; + foreach ( $pattern_contents as $slug => $content ) { + $patterns_block .= "=== " . esc_html( $slug ) . " ===\n" . $content . "\n\n"; + } + return "Theme/topic: " . esc_html( $theme ) . "\n\n" . + "Rewrite the text content in the WordPress block patterns below to match this theme.\n\n" . + $patterns_block; +} +``` + +**Benefits**: +- Easier to test prompt construction +- Easier to filter/modify prompts +- Better escaping control + +### 2. DRY Pattern Slug Validation (Low Priority) + +**Current**: `create_page()` has slug validation logic (lines 121-126 in `class-pattern-lab.php`) +**Issue**: This validation is duplicated when calling `Pattern_Lab::get_pattern_content()` in the rewrite flow +**Recommendation**: Consider a reusable `validate_pattern_slug()` method + +However, since `get_pattern_content()` uses the registry which already validates, this may not be necessary. + +### 3. Consolidate Model Preferences (Low Priority) + +**Current**: Model preference lists are duplicated in both AI calls (lines 186-193 and lines 128-135) +**Recommendation**: Extract to a constant or method + +```php +private static function get_model_preferences(): array { + return array( + 'mistral-large-latest', + 'mistral-small-latest', + 'claude-sonnet-4-6', + 'claude-opus-4-6', + 'claude-haiku-4-5', + 'gpt-4.1', + 'gemini-2.0-flash', + ); +} +``` + +**Benefits**: +- Single source of truth +- Easier to update model list +- More consistent across AI calls + +### 4. Type Safety Improvement (Low Priority) + +**Current**: Return type of `rewrite_pattern_texts()` is `array` (loose) +**Recommendation**: Use more specific PHPDoc + +```php +/** + * @return array Map of slug → rewritten block content. Empty on failure. + */ +``` + +This is already documented, but could be more explicit in the PHPDoc. + +### 5. Constant for Post Meta Keys (Low Priority) + +**Current**: Post meta keys are hardcoded strings: `'_waygate_personalized'`, `'_waygate_reasoning'`, etc. +**Recommendation**: Define constants for meta keys + +```php +// In waygate.php or a constants file +define( 'WAYGATE_META_PERSONALIZED', '_waygate_personalized' ); +define( 'WAYGATE_META_REASONING', '_waygate_reasoning' ); +define( 'WAYGATE_META_PATTERNS', '_waygate_patterns' ); +define( 'WAYGATE_META_GENERATED_AT', '_waygate_generated_at' ); +``` + +**Benefits**: +- Prevents typos in meta key names +- Easier to change keys across the codebase +- Better IDE support + +--- + +## Functional Improvements + +### 1. Add Progress Indicator (Medium Priority) + +**Current**: User submits form, waits for response with no feedback +**Issue**: With two AI calls, wait time can be significant +**Recommendation**: Add a loading spinner or progress message + +```html + + + + +``` + +### 2. Add Filter for Personalize Text Default (Low Priority) + +**Current**: Default is hardcoded to `true` in `generate_page()` signature +**Recommendation**: Make it filterable + +```php +$personalize_text_default = apply_filters( 'waygate_personalize_text_default', true ); +public static function generate_page( string $description, bool $personalize_text = $personalize_text_default ): array +``` + +**Benefits**: Sites can set their preferred default via filter + +### 3. Add Filter for AI Rewrite Schema (Low Priority) + +**Current**: JSON schema for rewrite response is hardcoded +**Recommendation**: Make schema filterable + +```php +$schema = apply_filters( 'waygate_rewrite_pattern_texts_schema', $schema, $pattern_contents ); +``` + +**Benefits**: Allows customization of expected response format + +### 4. Batch Size Limit for Rewrite (Low Priority) + +**Current**: All patterns are sent in one AI call regardless of count +**Issue**: Very large pattern sets could hit token limits +**Recommendation**: Add batch processing + +```php +const MAX_REWRITE_BATCH = 10; // patterns per AI call + +public static function rewrite_pattern_texts( array $slugs, string $theme ): array { + $all_results = array(); + $batches = array_chunk( $slugs, MAX_REWRITE_BATCH ); + + foreach ( $batches as $batch ) { + $results = self::rewrite_pattern_texts_batch( $batch, $theme ); + $all_results = array_merge( $all_results, $results ); + } + + return $all_results; +} +``` + +### 5. Add Character/Token Limit Warning (Low Priority) + +**Current**: No warning if user description is too long +**Recommendation**: Add client-side validation + +```javascript +// In the form +