Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <map.json>` 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=<slug>`, `--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
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <map.json>` 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
Expand Down
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <map.json>` 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

---
Expand Down Expand Up @@ -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.

---

Expand Down
22 changes: 21 additions & 1 deletion includes/class-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}
}
Expand Down Expand Up @@ -166,6 +171,21 @@ class="large-text"
><?php echo esc_textarea( sanitize_textarea_field( wp_unslash( $_POST['description'] ?? '' ) ) ); ?></textarea>
<p class="description">Be specific about industry, page type, and sections you need. Replace any <code>[placeholder]</code> text with your specifics.</p>
</td>
</tr>
<tr>
<th scope="row"><label for="waygate-vertical">Vertical</label></th>
<td>
<?php $selected_vertical = isset( $_POST['vertical'] ) ? sanitize_key( wp_unslash( $_POST['vertical'] ) ) : Pattern_Lab::get_default_vertical(); ?>
<select id="waygate-vertical" name="vertical" style="max-width:400px">
<option value="">All patterns</option>
<?php foreach ( Pattern_Lab::get_verticals() as $vertical_slug ) : ?>
<option value="<?php echo esc_attr( $vertical_slug ); ?>" <?php selected( $selected_vertical, $vertical_slug ); ?>>
<?php echo esc_html( ucwords( str_replace( '-', ' ', $vertical_slug ) ) ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description">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.</p>
</td>
</tr>
<tr>
<th scope="row">Text personalization</th>
Expand Down
69 changes: 61 additions & 8 deletions includes/class-ai-integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.';

Expand Down Expand Up @@ -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 ) {
Expand Down
Loading
Loading