From a5cdfae3176960a5a6e802a2f377b9acb2f67b46 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 26 Oct 2026 11:48:58 +0700 Subject: [PATCH 1/5] feat: scope AI pattern choice to page sections and verticals --- includes/class-admin.php | 22 ++++- includes/class-ai-integration.php | 69 +++++++++++++-- includes/class-pattern-lab.php | 116 ++++++++++++++++++++++++- tests/Unit/PatternScopingTest.php | 136 ++++++++++++++++++++++++++++++ tests/bootstrap.php | 18 ++++ 5 files changed, 350 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/PatternScopingTest.php diff --git a/includes/class-admin.php b/includes/class-admin.php index 75fb53f..284e4e6 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -76,11 +76,16 @@ public static function render_page(): void { } else { $description = sanitize_textarea_field( wp_unslash( $_POST['description'] ?? '' ) ); $personalize_text = ! empty( $_POST['personalize_text'] ); + $vertical = sanitize_key( wp_unslash( $_POST['vertical'] ?? '' ) ); + + if ( '' !== $vertical && ! in_array( $vertical, Pattern_Lab::get_verticals(), true ) ) { + $vertical = ''; + } if ( empty( $description ) ) { $result = array( 'error' => 'Please describe the page you want to create.' ); } else { - $result = AI_Integration::generate_page( $description, $personalize_text ); + $result = AI_Integration::generate_page( $description, $personalize_text, $vertical ); } } } @@ -166,6 +171,21 @@ class="large-text" >

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

+ + + + + + +

Only offer this vertical's patterns to the AI, so a law-firm page doesn't get a spa hero. Defaults to the active child theme's vertical. Falls back to all patterns if the vertical has fewer than three sections.

+ Text personalization diff --git a/includes/class-ai-integration.php b/includes/class-ai-integration.php index baa34f0..496237a 100644 --- a/includes/class-ai-integration.php +++ b/includes/class-ai-integration.php @@ -192,23 +192,69 @@ public static function rewrite_pattern_texts( array $slugs, string $theme ): arr return is_array( $data ) ? $data : array(); } + /** + * Return the page section patterns the AI may choose from. + * + * Scoped to the vertical when it has at least three sections (a hero, a body section and + * a CTA); otherwise falls back to every page section. + * + * @param string $vertical Optional vertical slug. + * @return array[] + */ + public static function get_candidate_patterns( string $vertical = '' ): array { + if ( '' !== $vertical ) { + $scoped = Pattern_Lab::get_patterns( + array( + 'sections_only' => true, + 'vertical' => $vertical, + ) + ); + + if ( count( $scoped ) >= 3 ) { + return $scoped; + } + } + + return Pattern_Lab::get_patterns( array( 'sections_only' => true ) ); + } + + /** + * Format patterns as one prompt line each: slug | title | categories | description | keywords. + * + * @param array[] $patterns Patterns from Pattern_Lab::get_patterns(). + * @return string + */ + public static function describe_patterns( array $patterns ): string { + $lines = ''; + + foreach ( $patterns as $p ) { + $cats = implode( ', ', array_map( fn( $c ) => str_replace( 'elayne/', '', $c ), $p['categories'] ) ); + $keywords = implode( ', ', $p['keywords'] ); + $lines .= "- {$p['slug']} | {$p['title']} | {$cats} | {$p['description']} | {$keywords}\n"; + } + + return $lines; + } + /** * Ask the AI to select patterns and assemble a draft page. * * @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. + * @param string $vertical Optional vertical slug (e.g. "legal"); limits the choice to that + * vertical's patterns when it has at least three page sections. * @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, bool $personalize_text = true ): array { - $patterns = Pattern_Lab::get_patterns(); - $pattern_detail = ''; + public static function generate_page( string $description, bool $personalize_text = true, string $vertical = '' ): array { + $patterns = self::get_candidate_patterns( $vertical ); - foreach ( $patterns as $p ) { - $cats = implode( ', ', array_map( fn( $c ) => str_replace( 'elayne/', '', $c ), $p['categories'] ) ); - $pattern_detail .= "- {$p['slug']} | {$p['title']} | {$cats}\n"; + if ( empty( $patterns ) ) { + return array( 'error' => 'No page section patterns are registered.' ); } + $pattern_detail = self::describe_patterns( $patterns ); + $schema = array( 'type' => 'object', 'properties' => array( @@ -230,7 +276,7 @@ public static function generate_page( string $description, bool $personalize_tex ); $prompt = "User request: {$description}\n\n" - . "Available Elayne patterns (slug | title | categories):\n" + . "Available Elayne patterns (slug | title | categories | description | keywords):\n" . "{$pattern_detail}\n" . 'Select the best patterns to assemble a page for this request.'; @@ -268,8 +314,15 @@ public static function generate_page( string $description, bool $personalize_tex return array( 'error' => 'AI returned an unexpected response. Raw output: ' . esc_html( substr( $raw, 0, 300 ) ) ); } + // Keep only slugs that were offered, so the AI cannot place a header, template or other vertical. + $offered = array_column( $patterns, 'slug' ); + $slugs = array_values( array_filter( (array) $data['patterns'], fn( $s ) => in_array( $s, $offered, true ) ) ); + + if ( empty( $slugs ) ) { + return array( 'error' => 'AI selected no patterns from the offered list.' ); + } + $page_title = $data['title'] ?? $description; - $slugs = $data['patterns']; $personalized = false; if ( $personalize_text ) { diff --git a/includes/class-pattern-lab.php b/includes/class-pattern-lab.php index f1ecdb1..4828ba3 100644 --- a/includes/class-pattern-lab.php +++ b/includes/class-pattern-lab.php @@ -22,13 +22,43 @@ public static function init(): void { } /** - * Return all registered Elayne patterns with key metadata. + * Pattern categories that hold site structure or whole pages rather than page sections. * + * @var string[] + */ + private static array $structural_categories = array( + 'header', + 'footer', + 'elayne/header', + 'elayne/footer', + 'elayne/page', + 'elayne/pages', + 'elayne/page-layouts', + ); + + /** + * Built-in vertical slugs, matching Elayne's vertical pattern categories (`elayne/{slug}`). + * + * @var string[] + */ + private static array $verticals = array( 'food-beverage', 'legal', 'plumbing', 'salon', 'spa' ); + + /** + * Return registered patterns with key metadata. + * + * @param array{sections_only?:bool,vertical?:string} $args { + * Optional filters. + * + * @type bool $sections_only Only return patterns that can be placed as a page section + * (no headers, footers, templates or full-page composites). + * @type string $vertical Only return patterns in this vertical's category, e.g. "legal". + * } * @return array[] */ - public static function get_patterns(): array { + public static function get_patterns( array $args = array() ): array { $all = \WP_Block_Patterns_Registry::get_instance()->get_all_registered(); $prefixes = apply_filters( 'waygate_pattern_prefixes', array( 'elayne/' ) ); + $vertical = sanitize_key( $args['vertical'] ?? '' ); $patterns = array(); foreach ( $all as $p ) { @@ -48,6 +78,14 @@ public static function get_patterns(): array { continue; } + if ( ! empty( $args['sections_only'] ) && ! self::is_page_section( $p ) ) { + continue; + } + + if ( '' !== $vertical && ! self::in_vertical( $p, $vertical ) ) { + continue; + } + $patterns[] = array( 'slug' => $p['slug'], 'title' => $p['title'] ?? '', @@ -60,6 +98,80 @@ public static function get_patterns(): array { return $patterns; } + /** + * Whether a registered pattern can be placed as a section of a page. + * + * Excludes patterns hidden from the inserter, template and template-part patterns, + * and anything in a header, footer or full-page category. + * + * @param array $pattern Raw pattern data from the registry. + * @return bool + */ + public static function is_page_section( array $pattern ): bool { + if ( isset( $pattern['inserter'] ) && false === $pattern['inserter'] ) { + return false; + } + + if ( ! empty( $pattern['templateTypes'] ) ) { + return false; + } + + foreach ( $pattern['blockTypes'] ?? array() as $block_type ) { + if ( str_starts_with( $block_type, 'core/template-part' ) ) { + return false; + } + } + + $excluded = apply_filters( 'waygate_structural_categories', self::$structural_categories ); + + return empty( array_intersect( $pattern['categories'] ?? array(), $excluded ) ); + } + + /** + * Whether a pattern belongs to a vertical, by a category ending in "/{vertical}". + * + * @param array $pattern Raw pattern data from the registry. + * @param string $vertical Vertical slug, e.g. "legal". + * @return bool + */ + private static function in_vertical( array $pattern, string $vertical ): bool { + foreach ( $pattern['categories'] ?? array() as $category ) { + if ( $category === $vertical || str_ends_with( $category, '/' . $vertical ) ) { + return true; + } + } + return false; + } + + /** + * Return the known vertical slugs, filterable via waygate_verticals. + * + * @return string[] + */ + public static function get_verticals(): array { + return apply_filters( 'waygate_verticals', self::$verticals ); + } + + /** + * Return the vertical of the active child theme, or an empty string. + * + * A child theme named "{parent}-{vertical}" (e.g. "elayne-legal") maps to that vertical. + * + * @return string + */ + public static function get_default_vertical(): string { + $stylesheet = get_stylesheet(); + $template = get_template(); + + if ( $stylesheet === $template || ! str_starts_with( $stylesheet, $template . '-' ) ) { + return ''; + } + + $vertical = substr( $stylesheet, strlen( $template ) + 1 ); + + return in_array( $vertical, self::get_verticals(), true ) ? $vertical : ''; + } + /** * Return the raw block content of a registered pattern, or an empty string if not found. * diff --git a/tests/Unit/PatternScopingTest.php b/tests/Unit/PatternScopingTest.php new file mode 100644 index 0000000..8846fa2 --- /dev/null +++ b/tests/Unit/PatternScopingTest.php @@ -0,0 +1,136 @@ +reset(); + $GLOBALS['wp_filters'] = []; + $GLOBALS['wp_theme'] = [ 'stylesheet' => 'elayne', 'template' => 'elayne' ]; + } + + private function register( string $slug, array $categories = [], array $extra = [] ): void { + WP_Block_Patterns_Registry::get_instance()->register( + array_merge( + [ + 'slug' => $slug, + 'title' => $slug, + 'description' => '', + 'categories' => $categories, + 'keywords' => [], + ], + $extra + ) + ); + } + + private function slugs( array $patterns ): array { + return array_column( $patterns, 'slug' ); + } + + // --- is_page_section() / sections_only --- + + public function test_sections_only_excludes_structural_patterns(): void { + $this->register( 'elayne/hero', [ 'elayne/hero' ] ); + $this->register( 'elayne/header-main', [ 'header' ] ); + $this->register( 'elayne/footer-main', [ 'elayne/footer' ] ); + $this->register( 'elayne/page-home', [ 'elayne/page', 'elayne/legal' ] ); + $this->register( 'elayne/template-index', [], [ 'templateTypes' => [ 'index' ] ] ); + $this->register( 'elayne/hidden', [], [ 'inserter' => false ] ); + $this->register( 'elayne/part', [], [ 'blockTypes' => [ 'core/template-part/header' ] ] ); + + $this->assertSame( [ 'elayne/hero' ], $this->slugs( Pattern_Lab::get_patterns( [ 'sections_only' => true ] ) ) ); + } + + public function test_default_get_patterns_still_returns_everything(): void { + $this->register( 'elayne/hero', [ 'elayne/hero' ] ); + $this->register( 'elayne/header-main', [ 'header' ] ); + + $this->assertCount( 2, Pattern_Lab::get_patterns() ); + } + + public function test_structural_categories_are_filterable(): void { + $this->register( 'elayne/posts-grid', [ 'elayne/posts' ] ); + + add_filter( 'waygate_structural_categories', fn( $cats ) => array_merge( $cats, [ 'elayne/posts' ] ) ); + + $this->assertCount( 0, Pattern_Lab::get_patterns( [ 'sections_only' => true ] ) ); + } + + // --- vertical --- + + public function test_vertical_filter_keeps_only_that_vertical(): void { + $this->register( 'elayne/legal-hero-form', [ 'elayne/hero', 'elayne/legal' ] ); + $this->register( 'elayne/spa-hero', [ 'elayne/hero', 'elayne/spa' ] ); + $this->register( 'elayne/hero-generic', [ 'elayne/hero' ] ); + + $this->assertSame( [ 'elayne/legal-hero-form' ], $this->slugs( Pattern_Lab::get_patterns( [ 'vertical' => 'legal' ] ) ) ); + } + + public function test_default_vertical_from_child_theme_name(): void { + $GLOBALS['wp_theme'] = [ 'stylesheet' => 'elayne-legal', 'template' => 'elayne' ]; + $this->assertSame( 'legal', Pattern_Lab::get_default_vertical() ); + } + + public function test_default_vertical_empty_for_parent_or_unknown_child(): void { + $this->assertSame( '', Pattern_Lab::get_default_vertical() ); + + $GLOBALS['wp_theme'] = [ 'stylesheet' => 'elayne-dentist', 'template' => 'elayne' ]; + $this->assertSame( '', Pattern_Lab::get_default_vertical() ); + + $GLOBALS['wp_theme'] = [ 'stylesheet' => 'ecotuin', 'template' => 'aviendha' ]; + $this->assertSame( '', Pattern_Lab::get_default_vertical() ); + } + + public function test_verticals_are_filterable(): void { + add_filter( 'waygate_verticals', fn( $v ) => array_merge( $v, [ 'dentist' ] ) ); + $GLOBALS['wp_theme'] = [ 'stylesheet' => 'elayne-dentist', 'template' => 'elayne' ]; + + $this->assertSame( 'dentist', Pattern_Lab::get_default_vertical() ); + } + + // --- AI candidates --- + + public function test_candidates_scope_to_vertical_sections(): void { + $this->register( 'elayne/legal-hero-form', [ 'elayne/legal' ] ); + $this->register( 'elayne/legal-departments', [ 'elayne/legal' ] ); + $this->register( 'elayne/legal-cta-strip', [ 'elayne/legal' ] ); + $this->register( 'elayne/page-legal-home', [ 'elayne/page', 'elayne/legal' ] ); + $this->register( 'elayne/spa-hero', [ 'elayne/spa' ] ); + + $this->assertSame( + [ 'elayne/legal-hero-form', 'elayne/legal-departments', 'elayne/legal-cta-strip' ], + $this->slugs( AI_Integration::get_candidate_patterns( 'legal' ) ) + ); + } + + public function test_candidates_fall_back_when_vertical_too_small(): void { + $this->register( 'elayne/legal-hero-form', [ 'elayne/legal' ] ); + $this->register( 'elayne/spa-hero', [ 'elayne/spa' ] ); + + $this->assertCount( 2, AI_Integration::get_candidate_patterns( 'legal' ) ); + } + + public function test_describe_patterns_includes_description_and_keywords(): void { + $line = AI_Integration::describe_patterns( + [ + [ + 'slug' => 'elayne/legal-hero-form', + 'title' => 'Legal Hero', + 'description' => 'Hero with enquiry form', + 'categories' => [ 'elayne/hero', 'elayne/legal' ], + 'keywords' => [ 'law', 'form' ], + ], + ] + ); + + $this->assertSame( "- elayne/legal-hero-form | Legal Hero | hero, legal | Hero with enquiry form | law, form\n", $line ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 1311485..1c630a7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -34,6 +34,24 @@ function wp_kses_post( string $data ): string { return preg_replace( '#]*>[\s\S]*?#i', '', $data ); } +$GLOBALS['wp_theme'] = [ 'stylesheet' => 'elayne', 'template' => 'elayne' ]; + +function get_stylesheet(): string { + return $GLOBALS['wp_theme']['stylesheet']; +} + +function get_template(): string { + return $GLOBALS['wp_theme']['template']; +} + +function sanitize_title( string $title ): string { + return strtolower( trim( preg_replace( '/[^A-Za-z0-9-]+/', '-', $title ), '-' ) ); +} + +function wp_slash( mixed $value ): mixed { + return is_array( $value ) ? array_map( 'wp_slash', $value ) : ( is_string( $value ) ? addslashes( $value ) : $value ); +} + function esc_attr( string $str ): string { return htmlspecialchars( $str, ENT_QUOTES ); } From e9d26c917c75637b210ef461cddcd07e2e99f28c Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 26 Oct 2026 11:48:58 +0700 Subject: [PATCH 2/5] feat: add wp waygate build and list commands --- includes/class-cli.php | 232 +++++++++++++++++++++++++++++++++ includes/class-pattern-lab.php | 57 ++++++++ tests/Unit/CliTest.php | 56 ++++++++ tests/Unit/PageBuildTest.php | 77 +++++++++++ waygate.php | 5 + 5 files changed, 427 insertions(+) create mode 100644 includes/class-cli.php create mode 100644 tests/Unit/CliTest.php create mode 100644 tests/Unit/PageBuildTest.php diff --git a/includes/class-cli.php b/includes/class-cli.php new file mode 100644 index 0000000..9a14751 --- /dev/null +++ b/includes/class-cli.php @@ -0,0 +1,232 @@ + $page ) { + $label = 'pages[' . $i . ']'; + + if ( ! is_array( $page ) ) { + return new \WP_Error( 'invalid_map', "{$label} must be an object." ); + } + + $id = isset( $page['id'] ) ? (int) $page['id'] : 0; + $slug = isset( $page['slug'] ) ? sanitize_title( (string) $page['slug'] ) : ''; + + if ( $id <= 0 && '' === $slug ) { + return new \WP_Error( 'invalid_map', "{$label} needs an \"id\" or a \"slug\"." ); + } + + if ( empty( $page['patterns'] ) || ! is_array( $page['patterns'] ) ) { + return new \WP_Error( 'invalid_map', "{$label} needs a non-empty \"patterns\" array." ); + } + + $status = $page['status'] ?? 'draft'; + if ( ! in_array( $status, array( 'draft', 'publish' ), true ) ) { + return new \WP_Error( 'invalid_map', "{$label} status must be \"draft\" or \"publish\"." ); + } + + $pages[] = array( + 'id' => $id, + 'slug' => $slug, + 'title' => isset( $page['title'] ) ? sanitize_text_field( (string) $page['title'] ) : '', + 'patterns' => array_values( $page['patterns'] ), + 'template' => isset( $page['template'] ) ? sanitize_text_field( (string) $page['template'] ) : '', + 'status' => $status, + ); + } + + return $pages; + } + + /** + * Builds pages from a JSON map of pattern slugs, without AI. + * + * Each page's content is replaced with its patterns' content, as a fresh insert in the + * block editor would store it. Pages found by `id` or `slug` are updated; a `slug` with no + * matching page creates one. + * + * ## OPTIONS + * + * + * : Path to the JSON map file. + * + * [--dry-run] + * : Show what would change without writing. + * + * [--references] + * : Store `wp:pattern` reference blocks instead of inlining the pattern content. + * + * ## EXAMPLES + * + * wp waygate build legal.json --dry-run + * wp waygate build legal.json --url=demo.imagewize.test/legal/ + * + * @param string[] $args Positional arguments. + * @param array $assoc_args Flags. + */ + public function build( array $args, array $assoc_args ): void { + $file = $args[0]; + + if ( ! is_readable( $file ) ) { + \WP_CLI::error( "Cannot read map file: {$file}" ); + } + + $pages = self::parse_map( (string) file_get_contents( $file ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file. + + if ( is_wp_error( $pages ) ) { + \WP_CLI::error( $pages->get_error_message() ); + } + + $dry_run = (bool) \WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false ); + $expand = ! \WP_CLI\Utils\get_flag_value( $assoc_args, 'references', false ); + $failed = 0; + + foreach ( $pages as $page ) { + $content = Pattern_Lab::build_content( $page['patterns'], $expand ); + $label = $page['id'] > 0 ? "page {$page['id']}" : "page /{$page['slug']}/"; + + if ( is_wp_error( $content ) ) { + \WP_CLI::warning( "{$label}: " . $content->get_error_message() ); + ++$failed; + continue; + } + + $post = $page['id'] > 0 ? get_post( $page['id'] ) : get_page_by_path( $page['slug'] ); + + if ( $page['id'] > 0 && ( ! $post || 'page' !== $post->post_type ) ) { + \WP_CLI::warning( "{$label}: no page with this ID." ); + ++$failed; + continue; + } + + $source = implode( ' + ', $page['patterns'] ); + + if ( $dry_run ) { + $action = $post ? 'update' : 'create'; + \WP_CLI::log( "DRY-RUN {$action} {$label} ← {$source} (" . strlen( $content ) . ' bytes)' ); + continue; + } + + $postarr = array( 'post_content' => $content ); + + if ( '' !== $page['template'] ) { + $postarr['page_template'] = $page['template']; + } + + if ( $post ) { + $postarr['ID'] = $post->ID; + // wp_update_post() unslashes its input; slash it so backslashes in block JSON survive. + $result = wp_update_post( wp_slash( $postarr ), true ); + } else { + $postarr['post_type'] = 'page'; + $postarr['post_name'] = $page['slug']; + $postarr['post_title'] = '' !== $page['title'] ? $page['title'] : ucwords( str_replace( '-', ' ', $page['slug'] ) ); + $postarr['post_status'] = $page['status']; + $result = wp_insert_post( wp_slash( $postarr ), true ); + } + + if ( is_wp_error( $result ) ) { + \WP_CLI::warning( "{$label}: " . $result->get_error_message() ); + ++$failed; + continue; + } + + \WP_CLI::success( ( $post ? 'Updated' : 'Created' ) . " page {$result} ← {$source}" ); + } + + if ( $failed > 0 ) { + \WP_CLI::error( "{$failed} page(s) failed." ); + } + } + + /** + * Lists registered patterns. + * + * ## OPTIONS + * + * [--vertical=] + * : Only patterns in this vertical, e.g. legal. + * + * [--sections] + * : Only patterns that can be placed as a page section (no headers, footers, templates or page composites). + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - ids + * --- + * + * ## EXAMPLES + * + * wp waygate list --vertical=legal --sections + * + * @subcommand list + * + * @param string[] $args Positional arguments. + * @param array $assoc_args Flags. + */ + public function list_( array $args, array $assoc_args ): void { + $patterns = Pattern_Lab::get_patterns( + array( + 'sections_only' => (bool) \WP_CLI\Utils\get_flag_value( $assoc_args, 'sections', false ), + 'vertical' => (string) \WP_CLI\Utils\get_flag_value( $assoc_args, 'vertical', '' ), + ) + ); + + usort( $patterns, fn( $a, $b ) => strcmp( $a['slug'], $b['slug'] ) ); + + $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' ); + + if ( 'ids' === $format ) { + \WP_CLI::log( implode( ' ', array_column( $patterns, 'slug' ) ) ); + return; + } + + $rows = array_map( + fn( $p ) => array( + 'slug' => $p['slug'], + 'title' => $p['title'], + 'categories' => implode( ', ', $p['categories'] ), + ), + $patterns + ); + + \WP_CLI\Utils\format_items( $format, $rows, array( 'slug', 'title', 'categories' ) ); + } +} diff --git a/includes/class-pattern-lab.php b/includes/class-pattern-lab.php index 4828ba3..49499bd 100644 --- a/includes/class-pattern-lab.php +++ b/includes/class-pattern-lab.php @@ -172,6 +172,63 @@ public static function get_default_vertical(): string { return in_array( $vertical, self::get_verticals(), true ) ? $vertical : ''; } + /** + * Replace nested `wp:pattern` reference blocks with the referenced pattern's content. + * + * Mirrors what the block editor stores when a pattern is inserted. References to + * unregistered slugs are left in place. + * + * @param string $content Block markup. + * @param int $depth Remaining recursion depth, guards against self-referencing patterns. + * @return string + */ + public static function expand_pattern_references( string $content, int $depth = 5 ): string { + if ( $depth <= 0 || ! str_contains( $content, '#', + function ( array $m ) use ( $depth ): string { + $inner = self::get_pattern_content( $m[1] ); + return '' === $inner ? $m[0] : self::expand_pattern_references( $inner, $depth - 1 ); + }, + $content + ); + } + + /** + * Concatenate the content of an ordered list of registered patterns into page markup. + * + * @param string[] $slugs Ordered pattern slugs. Each must be a registered pattern matching + * the waygate_pattern_prefixes filter. + * @param bool $expand True to inline each pattern's content (nested references expanded), + * false to emit `wp:pattern` reference blocks. + * @return string|\WP_Error Block markup, or WP_Error naming the first unknown slug. + */ + public static function build_content( array $slugs, bool $expand = true ) { + $registered = array_column( self::get_patterns(), 'slug' ); + $chunks = array(); + + foreach ( $slugs as $slug ) { + if ( ! is_string( $slug ) || ! in_array( $slug, $registered, true ) ) { + return new \WP_Error( 'unknown_pattern', 'Pattern is not registered: ' . ( is_string( $slug ) ? $slug : gettype( $slug ) ) ); + } + + $chunks[] = $expand + ? self::expand_pattern_references( self::get_pattern_content( $slug ) ) + : ''; + } + + $content = implode( "\n\n", array_filter( array_map( 'trim', $chunks ) ) ); + + if ( '' === $content ) { + return new \WP_Error( 'no_content', 'The patterns produced no content.' ); + } + + return $content; + } + /** * Return the raw block content of a registered pattern, or an empty string if not found. * diff --git a/tests/Unit/CliTest.php b/tests/Unit/CliTest.php new file mode 100644 index 0000000..8b07b23 --- /dev/null +++ b/tests/Unit/CliTest.php @@ -0,0 +1,56 @@ + [ + [ 'id' => 5, 'patterns' => [ 'elayne/legal-hero-form' ] ], + [ + 'slug' => 'about', + 'title' => 'About us', + 'patterns' => [ 'elayne/page-legal-about' ], + 'template' => 'page-no-title', + ], + ], + ] + ) + ); + + $this->assertIsArray( $pages ); + $this->assertCount( 2, $pages ); + $this->assertSame( 5, $pages[0]['id'] ); + $this->assertSame( 'draft', $pages[0]['status'] ); + $this->assertSame( 'about', $pages[1]['slug'] ); + $this->assertSame( 'About us', $pages[1]['title'] ); + $this->assertSame( 'page-no-title', $pages[1]['template'] ); + } + + #[DataProvider( 'invalid_maps' )] + public function test_parse_map_rejects_invalid_maps( string $json, string $expected ): void { + $result = CLI::parse_map( $json ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertStringContainsString( $expected, $result->get_error_message() ); + } + + public static function invalid_maps(): array { + return [ + 'not json' => [ 'nope', '"pages" array' ], + 'no pages' => [ '{"pages":[]}', '"pages" array' ], + 'no id or slug' => [ '{"pages":[{"patterns":["elayne/a"]}]}', '"id" or a "slug"' ], + 'no patterns' => [ '{"pages":[{"id":5}]}', '"patterns" array' ], + 'bad status' => [ '{"pages":[{"id":5,"patterns":["elayne/a"],"status":"trash"}]}', 'status' ], + 'page not object' => [ '{"pages":["elayne/a"]}', 'must be an object' ], + ]; + } +} diff --git a/tests/Unit/PageBuildTest.php b/tests/Unit/PageBuildTest.php new file mode 100644 index 0000000..be7793f --- /dev/null +++ b/tests/Unit/PageBuildTest.php @@ -0,0 +1,77 @@ +reset(); + $GLOBALS['wp_filters'] = []; + } + + private function register( string $slug, array $categories = [], array $extra = [] ): void { + WP_Block_Patterns_Registry::get_instance()->register( + array_merge( + [ + 'slug' => $slug, + 'title' => $slug, + 'description' => '', + 'categories' => $categories, + 'keywords' => [], + ], + $extra + ) + ); + } + + // --- expand_pattern_references() --- + + public function test_expand_inlines_nested_references(): void { + $this->register( 'elayne/cta', [], [ 'content' => '

CTA

' ] ); + $this->register( 'elayne/page', [], [ 'content' => "

Top

\n" ] ); + + $this->assertSame( "

Top

\n

CTA

", Pattern_Lab::expand_pattern_references( '' ) ); + } + + public function test_expand_leaves_unknown_references(): void { + $ref = ''; + $this->assertSame( $ref, Pattern_Lab::expand_pattern_references( $ref ) ); + } + + public function test_expand_stops_on_self_reference(): void { + $self = ''; + $this->register( 'elayne/loop', [], [ 'content' => $self ] ); + + $this->assertSame( $self, Pattern_Lab::expand_pattern_references( $self ) ); + } + + // --- build_content() --- + + public function test_build_content_inlines_in_order(): void { + $this->register( 'elayne/a', [], [ 'content' => '

A

' ] ); + $this->register( 'elayne/b', [], [ 'content' => '

B

' ] ); + + $this->assertSame( "

B

\n\n

A

", Pattern_Lab::build_content( [ 'elayne/b', 'elayne/a' ] ) ); + } + + public function test_build_content_emits_references_when_not_expanding(): void { + $this->register( 'elayne/a', [], [ 'content' => '

A

' ] ); + + $this->assertSame( '', Pattern_Lab::build_content( [ 'elayne/a' ], false ) ); + } + + public function test_build_content_rejects_unknown_slug(): void { + $this->register( 'elayne/a', [], [ 'content' => '

A

' ] ); + + $result = Pattern_Lab::build_content( [ 'elayne/a', 'elayne/nope' ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertStringContainsString( 'elayne/nope', $result->get_error_message() ); + } +} diff --git a/waygate.php b/waygate.php index d968d3a..74476c4 100644 --- a/waygate.php +++ b/waygate.php @@ -33,3 +33,8 @@ add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Abilities_API', 'init' ) ); add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Admin', 'init' ) ); add_action( 'plugins_loaded', array( 'Imagewize\\Waygate\\Rest_API', 'init' ) ); + +if ( defined( 'WP_CLI' ) && WP_CLI ) { + require_once WAYGATE_PLUGIN_DIR . 'includes/class-cli.php'; + WP_CLI::add_command( 'waygate', 'Imagewize\\Waygate\\CLI' ); +} From ed63ac51878e2d2414bfc70c47454704e5ba511e Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 26 Oct 2026 11:49:36 +0700 Subject: [PATCH 3/5] fix: slash page content so block JSON backslashes survive --- includes/class-pattern-lab.php | 26 ++++++++++++++++---------- tests/Unit/PageBuildTest.php | 18 ++++++++++++++++++ tests/bootstrap.php | 2 ++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/includes/class-pattern-lab.php b/includes/class-pattern-lab.php index 49499bd..7d618dd 100644 --- a/includes/class-pattern-lab.php +++ b/includes/class-pattern-lab.php @@ -258,12 +258,15 @@ public static function create_page_from_content( string $title, array $block_con return new \WP_Error( 'no_content', 'No block content was provided.' ); } + // wp_insert_post() unslashes its input; slash it so backslashes in block JSON survive. 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', + wp_slash( + 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 ); @@ -299,12 +302,15 @@ public static function create_page( string $title, array $pattern_slugs, string return new \WP_Error( 'no_valid_patterns', 'No valid patterns were selected.' ); } + // wp_insert_post() unslashes its input; slash it so backslashes in block JSON survive. 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', + wp_slash( + 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 ); diff --git a/tests/Unit/PageBuildTest.php b/tests/Unit/PageBuildTest.php index be7793f..7aacc23 100644 --- a/tests/Unit/PageBuildTest.php +++ b/tests/Unit/PageBuildTest.php @@ -74,4 +74,22 @@ public function test_build_content_rejects_unknown_slug(): void { $this->assertInstanceOf( WP_Error::class, $result ); $this->assertStringContainsString( 'elayne/nope', $result->get_error_message() ); } + + // --- backslashes survive wp_insert_post() unslashing --- + + public function test_create_page_from_content_keeps_backslashes(): void { + $markup = '

a "quoted" \\ b

'; + + Pattern_Lab::create_page_from_content( 'Test', [ $markup ] ); + + $this->assertSame( $markup, $GLOBALS['wp_last_inserted_post']['post_content'] ); + } + + public function test_create_page_keeps_references_intact(): void { + $this->register( 'elayne/a', [], [ 'content' => '

A

' ] ); + + Pattern_Lab::create_page( 'Test', [ 'elayne/a' ] ); + + $this->assertSame( "\n\n", $GLOBALS['wp_last_inserted_post']['post_content'] ); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 1c630a7..cb2d64b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -61,6 +61,8 @@ function is_wp_error( mixed $thing ): bool { } function wp_insert_post( array $args, bool $wp_error = false ): int|WP_Error { + // Like core, unslash the input, so tests see what would reach the database. + $GLOBALS['wp_last_inserted_post'] = array_map( fn( $v ) => is_string( $v ) ? stripslashes( $v ) : $v, $args ); return 1; } From 5600ab3bdc635a5eb69c5ec1c225ca3227b2bb02 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 26 Oct 2026 11:50:14 +0700 Subject: [PATCH 4/5] docs: document WP-CLI commands and vertical scoping --- CLAUDE.md | 2 ++ README.md | 42 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ae9166b..b1ecc0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,8 @@ Each ability has a JSON input/output schema for capability validation. **`AI_Integration`** (`includes/class-ai-integration.php`) — Orchestrates AI page generation. Registers a Mistral provider with the WP AI Client registry, then in `generate_page()` builds a prompt from all available patterns, sends it to the AI (with fallback chain: Mistral → Claude → OpenAI → Gemini), parses the JSON response, and delegates to `Pattern_Lab::create_page()`. Enforces layout constraints: 3–7 patterns, hero first, CTA last, no consecutive grid patterns. +**`CLI`** (`includes/class-cli.php`) — WP-CLI commands, loaded only when `WP_CLI` is defined. `wp waygate build ` builds pages from a JSON map of pattern slugs without AI (`--dry-run`, `--references`); `wp waygate list` lists patterns (`--vertical`, `--sections`). Map parsing (`CLI::parse_map()`) and content assembly (`Pattern_Lab::build_content()`) are static so they are unit-tested; the command methods only do I/O. Writes go through `wp_slash()`, because `wp_update_post()`/`wp_insert_post()` unslash their input and would strip backslashes from block JSON. + **`Admin`** (`includes/class-admin.php`) — WordPress admin UI at **Tools → Waygate**. Renders status indicators (WP AI Client, Abilities API, Mistral provider, Elayne patterns), the AI generation form, and a searchable pattern catalog. Handles POST form submission with nonce verification and displays success/error notices. ### Initialization Order diff --git a/README.md b/README.md index 6c87274..a736385 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Waygate lets you assemble WordPress pages from block patterns — manually or vi - **Abilities API** — Exposes `elayne/list-patterns` and `elayne/create-page` server abilities plus a `waygate/insert-pattern` client-side ability for the block editor (WP 7.0+) - **REST API** — `GET /wp-json/waygate/v1/patterns` and `POST /wp-json/waygate/v1/pages` for headless and external tool integration - **Multi-provider** — Works with Mistral, Claude, OpenAI, or Gemini via WP AI Client +- **Vertical scoping** — Limit the AI to one vertical's patterns (e.g. legal), so a law-firm page doesn't get a spa hero; defaults to the active child theme's vertical +- **WP-CLI** — `wp waygate build ` builds pages from a pattern map without AI; `wp waygate list` lists patterns by vertical - **Any block theme** — Default prefix is `elayne/`; extend via the `waygate_pattern_prefixes` filter --- @@ -93,9 +95,43 @@ 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 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 +4. Pick a **Vertical** to limit the AI to that vertical's patterns (defaults to the active child theme's vertical, e.g. `legal` for Elayne Legal) +5. 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 +6. Click **Generate Page** to create an AI-assembled draft +7. Open the draft in the block editor, adjust as needed, and publish + +--- + +## WP-CLI + +Build pages from patterns without AI, for example to rebuild a demo site after a theme change: + +```bash +wp waygate build legal.json --dry-run +wp waygate build legal.json +``` + +The map lists pages and their patterns in order. A page is matched by `id` or `slug`; a slug with no matching page creates a draft. `title`, `template` and `status` (`draft` or `publish`) are optional. + +```json +{ + "pages": [ + { "id": 5, "patterns": ["elayne/legal-hero-form", "elayne/legal-departments", "elayne/legal-cta-strip"] }, + { "slug": "about", "title": "About", "patterns": ["elayne/page-legal-about"] } + ] +} +``` + +Content is stored as a fresh block-editor insert would store it: each pattern is inlined and nested `wp:pattern` references are expanded. Pass `--references` to store `wp:pattern` blocks instead. Unknown slugs fail that page and the command exits non-zero. + +List patterns to write a map: + +```bash +wp waygate list --vertical=legal --sections +wp waygate list --format=ids +``` + +`--sections` leaves out headers, footers, templates and full-page patterns. --- From 46cb9d8b1eb11d0d90443c8e18f7d01f17843de7 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Mon, 26 Oct 2026 11:50:14 +0700 Subject: [PATCH 5/5] docs: update CHANGELOG and bump version to 1.1.0 --- CHANGELOG.md | 18 ++++++++++++++++++ readme.txt | 13 ++++++++++++- waygate.php | 4 ++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5554e2..aa20776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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). +## [1.1.0] - 2026-10-26 + +### Added +- `wp waygate build ` WP-CLI command — builds pages from a JSON map of pattern slugs without AI. Pages are matched by `id` or `slug` (a slug with no page creates a draft); content is inlined as a fresh block-editor insert would store it, with nested `wp:pattern` references expanded. Supports `--dry-run` and `--references` (store `wp:pattern` blocks instead of inlining), plus optional per-page `title`, `template` and `status` +- `wp waygate list` WP-CLI command — lists registered patterns, with `--vertical=`, `--sections` and `--format` (table, json, csv, ids) +- **Vertical** select in the AI form (Tools → Waygate) — limits the AI to one vertical's patterns (e.g. legal), so a law-firm page doesn't get a spa hero. Defaults to the active child theme's vertical (`elayne-legal` → `legal`); falls back to all patterns when the vertical has fewer than three page sections +- `Pattern_Lab::get_patterns()` accepts `sections_only` and `vertical` arguments; new `is_page_section()`, `get_verticals()`, `get_default_vertical()`, `expand_pattern_references()` and `build_content()` +- `AI_Integration::generate_page()` takes an optional third `$vertical` argument; new `get_candidate_patterns()` and `describe_patterns()` +- Filters: `waygate_verticals` (vertical slugs, default `food-beverage`, `legal`, `plumbing`, `salon`, `spa`) and `waygate_structural_categories` (categories excluded from page sections) + +### Changed +- AI page generation only offers page sections: patterns hidden from the inserter, template and template-part patterns, and header, footer and full-page categories are no longer sent to the AI +- The AI prompt now includes each pattern's description and keywords, not only slug, title and categories +- Slugs the AI returns that were not in the offered list are dropped before the page is created + +### Fixed +- Pages created by Waygate lost every backslash in block markup (e.g. `\u002d` escapes and `\"` in block comment JSON), because `wp_insert_post()` unslashes its input. Content is now passed through `wp_slash()` in `create_page()` and `create_page_from_content()` + ## [1.0.0] - 2026-08-23 ### Added diff --git a/readme.txt b/readme.txt index bdd608e..82506f7 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: patterns, page-builder, ai, block-theme, gutenberg Requires at least: 7.0 Tested up to: 7.1 Requires PHP: 8.3 -Stable tag: 1.0.0 +Stable tag: 1.1.0 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -26,6 +26,8 @@ Waygate lets you assemble WordPress pages from block patterns — manually or vi * **Abilities API** — Exposes `elayne/list-patterns` and `elayne/create-page` server abilities plus a `waygate/insert-pattern` client-side ability for the block editor (WordPress 7.0+) * **REST API** — `GET /wp-json/waygate/v1/patterns` and `POST /wp-json/waygate/v1/pages` for headless and external tool integration * **Multi-provider** — Works with Mistral, Claude, OpenAI, or Gemini via the WordPress AI Client +* **Vertical scoping** — Limit the AI to one vertical's patterns (e.g. legal); defaults to the active child theme's vertical +* **WP-CLI** — `wp waygate build ` builds pages from a pattern map without AI; `wp waygate list` lists patterns * **Any block theme** — Default prefix is `elayne/`; extend via the `waygate_pattern_prefixes` filter = Requirements = @@ -69,6 +71,12 @@ Yes. Waygate exposes REST endpoints (`GET /wp-json/waygate/v1/patterns`, `POST / == Changelog == += 1.1.0 = +* Added: `wp waygate build ` builds pages from a JSON map of pattern slugs without AI, with `--dry-run` and `--references`; `wp waygate list` lists patterns by vertical or page sections +* Added: Vertical select in the AI form, defaulting to the active child theme's vertical; `waygate_verticals` and `waygate_structural_categories` filters +* Changed: The AI is only offered page sections (no headers, footers, templates or full-page patterns), sees pattern descriptions and keywords, and cannot place a slug it was not offered +* Fixed: Backslashes in block markup were stripped from created pages; content is now slashed before `wp_insert_post()` + = 1.0.0 = * Changed: First stable release; no longer marked Beta * Added: WordPress.org `readme.txt`, WPCS check, Plugin Check, and release-zip CI workflows @@ -97,6 +105,9 @@ https://github.com/imagewize/waygate/blob/main/CHANGELOG.md == Upgrade Notice == += 1.1.0 = +Adds WP-CLI build and list commands and vertical scoping for AI pages, and fixes stripped backslashes in created pages. No breaking changes. + = 1.0.0 = First stable release. No breaking changes. diff --git a/waygate.php b/waygate.php index 74476c4..5d3ed2c 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: 1.0.0 + * Version: 1.1.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', '1.0.0' ); +define( 'WAYGATE_VERSION', '1.1.0' ); define( 'WAYGATE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'WAYGATE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );