Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
38 changes: 38 additions & 0 deletions includes/Abstracts/Abstract_Experiment.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,44 @@ public function render_settings_fields(): void {
// Child classes can override to render custom settings UI.
}

/**
* Provides contextual entry points for the experiment.
*
* Child classes can override to return an array of links, for example:
* array(
* array(
* 'label' => __( 'Try', 'ai' ),
* 'url' => admin_url( 'post-new.php' ),
* 'type' => 'try',
* ),
* array(
* 'label' => __( 'Dashboard', 'ai' ),
* 'url' => admin_url( 'admin.php?page=ai-mcp' ),
* 'type' => 'dashboard',
* ),
* );
*
* @since 0.1.0
*
* @return array<int, array{label: string, url: string, type?: string}>
Comment on lines +217 to +228
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example in the documentation shows a 'type' field in the entry points array (line 217, 222), but this field is marked as optional in the PHPDoc type annotation and is not validated or used anywhere in the Settings_Page.php implementation. Either document what the 'type' field is intended for and implement its usage, or remove it from the example to avoid confusion.

Suggested change
* 'type' => 'try',
* ),
* array(
* 'label' => __( 'Dashboard', 'ai' ),
* 'url' => admin_url( 'admin.php?page=ai-mcp' ),
* 'type' => 'dashboard',
* ),
* );
*
* @since 0.1.0
*
* @return array<int, array{label: string, url: string, type?: string}>
* ),
* array(
* 'label' => __( 'Dashboard', 'ai' ),
* 'url' => admin_url( 'admin.php?page=ai-mcp' ),
* ),
* );
*
* @since 0.1.0
*
* @return array<int, array{label: string, url: string}>

Copilot uses AI. Check for mistakes.
*/
public function get_entry_points(): array {
return array();
}

/**
* Checks if the experiment has custom settings.
*
* Override this method in child classes that have settings to return true.
*
* @since 0.1.0
*
* @return bool True if the experiment has settings, false otherwise.
*/
public function has_settings(): bool {
return false;
}

/**
* Gets the option name for a custom experiment setting field.
*
Expand Down
27 changes: 27 additions & 0 deletions includes/Contracts/Experiment.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,31 @@ public function register(): void;
* @return bool True if enabled, false otherwise.
*/
public function is_enabled(): bool;

/**
* Provides contextual entry points for the experiment.
*
* @since 0.1.0
*
* @return array<int, array{label: string, url: string, type?: string}>
*/
public function get_entry_points(): array;

/**
* Checks if the experiment has custom settings.
*
* @since 0.1.0
*
* @return bool True if the experiment has settings, false otherwise.
*/
public function has_settings(): bool;

/**
* Renders experiment-specific settings fields.
*
* @since 0.1.0
*
* @return void
*/
public function render_settings_fields(): void;
}
132 changes: 118 additions & 14 deletions includes/Settings/Settings_Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ class Settings_Page {
*/
private const PAGE_SLUG = 'ai-experiments';

/**
* URL pointing to the plugin repository for contributions.
*
* @since 0.1.0
*
* @var string
*/
private const CONTRIBUTION_URL = 'https://github.com/WordPress/ai';

/**
* URL pointing to the plugin documentation.
*
* @since 0.1.0
*
* @var string
*/
private const DOCUMENTATION_URL = 'https://github.com/WordPress/ai/tree/develop/docs';

/**
* Constructor.
*
Expand Down Expand Up @@ -125,8 +143,37 @@ public function render_page(): void {

$global_enabled = (bool) get_option( Settings_Registration::GLOBAL_OPTION, false );
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<div class="wrap ai-experiments-page">
<div class="ai-admin-header">
<div class="ai-admin-header__inner">
<div class="ai-admin-header__left">
<span class="ai-admin-header__icon">
<?php echo \WordPress\AI\get_ai_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The phpcs:ignore comment disables output escaping without justification. While get_ai_icon_svg() returns SVG markup that needs to be unescaped, consider adding a comment explaining why this is safe (e.g., "Safe: SVG content is from a trusted static file, not user input").

Suggested change
<?php echo \WordPress\AI\get_ai_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php echo \WordPress\AI\get_ai_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Safe: SVG content is from a trusted static file, not user input. ?>

Copilot uses AI. Check for mistakes.
</span>
<div class="ai-admin-header__title">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
</div>
</div>
<div class="ai-admin-header__right">
<a
class="button button-secondary"
href="<?php echo esc_url( self::DOCUMENTATION_URL ); ?>"
target="_blank"
rel="noopener noreferrer"
>
<?php esc_html_e( 'Docs', 'ai' ); ?>
</a>
<a
class="button button-primary"
href="<?php echo esc_url( self::CONTRIBUTION_URL ); ?>"
target="_blank"
rel="noopener noreferrer"
>
<?php esc_html_e( 'Contribute', 'ai' ); ?>
</a>
</div>
</div>
</div>

<?php
// If we don't have proper credentials, show an error message and return early.
Expand All @@ -151,7 +198,6 @@ public function render_page(): void {
?>

<?php settings_errors( 'ai_experiments' ); ?>

<form method="post" action="options.php">
<?php
settings_fields( Settings_Registration::OPTION_GROUP );
Expand Down Expand Up @@ -200,16 +246,19 @@ public function render_page(): void {
<?php endif; ?>
</div>

<ul class="ai-experiments__list">
<div class="ai-experiments__grid">
<?php foreach ( $this->registry->get_all_experiments() as $experiment ) : ?>
<?php
$experiment_id = $experiment->get_id();
$experiment_option = "ai_experiment_{$experiment_id}_enabled";
$experiment_enabled = (bool) get_option( $experiment_option, false );
$disabled_class = ! $global_enabled ? 'ai-experiments__item--disabled' : '';
$desc_id = "ai-experiment-{$experiment_id}-desc";
$settings_id = "ai-experiment-{$experiment_id}-settings";
$has_settings = $experiment->has_settings();
$entry_points = $experiment->get_entry_points();
?>
<li class="ai-experiments__item <?php echo esc_attr( $disabled_class ); ?>">
<div class="ai-experiments__item <?php echo esc_attr( $disabled_class ); ?>">
<div class="ai-experiments__item-header">
<label class="components-toggle-control" for="<?php echo esc_attr( $experiment_option ); ?>">
<input
Expand All @@ -223,10 +272,43 @@ public function render_page(): void {
aria-describedby="<?php echo esc_attr( $desc_id ); ?>"
<?php endif; ?>
/>
<span>
<span class="ai-experiments__item-title">
<strong><?php echo esc_html( $experiment->get_label() ); ?></strong>
<?php if ( ! empty( $entry_points ) ) : ?>
<span class="ai-experiments__item-links">
<?php
$links = array();
foreach ( $entry_points as $action ) {
if ( empty( $action['label'] ) || empty( $action['url'] ) ) {
continue;
}
$links[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $action['url'] ),
Comment on lines +285 to +287
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Entry point validation only checks for 'label' and 'url' fields being non-empty, but doesn't validate that 'url' is actually a valid URL. Consider adding URL validation using esc_url_raw() or filter_var() before outputting to catch malformed URLs from experiment implementations.

Suggested change
$links[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $action['url'] ),
$action_url = esc_url_raw( (string) $action['url'] );
if ( empty( $action_url ) ) {
continue;
}
$links[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $action_url ),

Copilot uses AI. Check for mistakes.
esc_html( $action['label'] )
Comment on lines +282 to +288
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entry points loop uses continue when label or url is empty, but doesn't validate the array structure before accessing these keys. If an entry point is not an array or is malformed, this could cause PHP notices. Consider adding is_array($action) check before accessing array keys.

Suggested change
if ( empty( $action['label'] ) || empty( $action['url'] ) ) {
continue;
}
$links[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $action['url'] ),
esc_html( $action['label'] )
if ( ! is_array( $action ) ) {
continue;
}
$label = $action['label'] ?? '';
$url = $action['url'] ?? '';
if ( '' === $label || '' === $url ) {
continue;
}
$links[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $url ),
esc_html( $label )

Copilot uses AI. Check for mistakes.
);
}

if ( ! empty( $links ) ) {
echo wp_kses_post( '(' . implode( ' · ', $links ) . ')' );
}
?>
</span>
<?php endif; ?>
</span>
</label>
<?php if ( $has_settings ) : ?>
<button
type="button"
class="ai-experiments__settings-toggle"
aria-expanded="false"
aria-controls="<?php echo esc_attr( $settings_id ); ?>"
title="<?php esc_attr_e( 'Toggle settings', 'ai' ); ?>"
>
<span class="dashicons dashicons-admin-generic"></span>
<span class="screen-reader-text"><?php esc_html_e( 'Settings', 'ai' ); ?></span>
</button>
<?php endif; ?>
</div>
<?php if ( $experiment->get_description() ) : ?>
<p class="description" id="<?php echo esc_attr( $desc_id ); ?>">
Expand All @@ -249,15 +331,37 @@ public function render_page(): void {
?>
</p>
<?php endif; ?>
<?php
// Allow experiments to render their own custom settings fields.
if ( method_exists( $experiment, 'render_settings_fields' ) ) {
$experiment->render_settings_fields();
}
?>
</li>
<?php if ( $has_settings ) : ?>
<div
id="<?php echo esc_attr( $settings_id ); ?>"
class="ai-experiments__settings-drawer"
hidden
>
<?php $experiment->render_settings_fields(); ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</ul>
</div>
<script>
( function() {
document.querySelectorAll( '.ai-experiments__settings-toggle' ).forEach( function( btn ) {
btn.addEventListener( 'click', function() {
var expanded = btn.getAttribute( 'aria-expanded' ) === 'true';
var drawerId = btn.getAttribute( 'aria-controls' );
var drawer = document.getElementById( drawerId );
if ( drawer ) {
btn.setAttribute( 'aria-expanded', String( ! expanded ) );
if ( expanded ) {
drawer.setAttribute( 'hidden', '' );
} else {
drawer.removeAttribute( 'hidden' );
}
}
} );
} );
} )();
</script>
Comment on lines +346 to +364
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline JavaScript should be moved to a separate enqueued script file for better separation of concerns, security, and maintainability. Inline scripts in PHP templates make code harder to test and maintain. Consider creating a separate JS file in src/admin/settings/ and enqueuing it via Asset_Loader.

Copilot uses AI. Check for mistakes.
Comment on lines +346 to +364
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JavaScript accordion functionality is added inline on every page load, but it's not checking if experiments with settings actually exist before adding the event listeners. While harmless, this adds unnecessary DOM queries. Consider conditionally rendering the script only when at least one experiment has settings, by checking if any experiment returns true for has_settings() before the loop.

Copilot uses AI. Check for mistakes.
</div>
<?php endif; ?>
</div>
Expand Down
60 changes: 60 additions & 0 deletions includes/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,63 @@
return false;
}
}

/**
* Returns the AI icon as an inline SVG.
*
* @since 0.1.0
*
* @param string $width The width of the icon.
* @param string $height The height of the icon.
* @return string The AI icon SVG markup.
*/
function get_ai_icon_svg( string $width = '1em', string $height = '1em' ): string {
static $svg_content = null;

if ( null === $svg_content ) {
$svg_path = dirname( __DIR__ ) . '/assets/images/ai-icon.svg';
$svg_content = file_exists( $svg_path ) ? file_get_contents( $svg_path ) : '';
Comment on lines +277 to +278
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_ai_icon_svg() and get_ai_icon_data_uri() functions reference a file path 'assets/images/ai-icon.svg' that doesn't appear to exist in the repository. This will cause both functions to return empty strings. Ensure the ai-icon.svg file is added to the assets/images/ directory, or update the path if the file is located elsewhere.

Copilot uses AI. Check for mistakes.
}

if ( empty( $svg_content ) ) {
return '';
}

// Add width and height attributes, and fill="currentColor" for theme compatibility.
return preg_replace(
'/<svg\b/',
sprintf( '<svg width="%s" height="%s" fill="currentColor"', esc_attr( $width ), esc_attr( $height ) ),
$svg_content,
1
);
Comment on lines +286 to +291
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preg_replace function could fail and return null. Consider checking the return value or using preg_replace with error handling to ensure the function returns a valid string. You could use a fallback to return the original $svg_content if preg_replace fails.

Suggested change
return preg_replace(
'/<svg\b/',
sprintf( '<svg width="%s" height="%s" fill="currentColor"', esc_attr( $width ), esc_attr( $height ) ),
$svg_content,
1
);
$result = preg_replace(
'/<svg\b/',
sprintf( '<svg width="%s" height="%s" fill="currentColor"', esc_attr( $width ), esc_attr( $height ) ),
$svg_content,
1
);
if ( null === $result ) {
return $svg_content;
}
return $result;

Copilot uses AI. Check for mistakes.
}

/**
* Returns the AI icon as a base64 data URI for use in admin menu icons.
*
* @since 0.1.0
*
* @return string The base64-encoded data URI for the AI icon.
*/
function get_ai_icon_data_uri(): string {
static $data_uri = null;

if ( null === $data_uri ) {
$svg_path = dirname( __DIR__ ) . '/assets/images/ai-icon.svg';

if ( file_exists( $svg_path ) ) {
$svg_content = file_get_contents( $svg_path );

Check warning on line 308 in includes/helpers.php

View workflow job for this annotation

GitHub Actions / Run PHPCS coding standards checks

file_get_contents() is uncached. If the function is being used to fetch a remote file (e.g. a URL starting with https://), please use wpcom_vip_file_get_contents() to ensure the results are cached. For more details, please see: https://docs.wpvip.com/technical-references/code-quality-and-best-practices/retrieving-remote-data/
if ( false === $svg_content ) {
$data_uri = '';
return $data_uri;
}
// Replace currentColor with a neutral color for admin menu compatibility.
$svg_content = str_replace( 'fill="currentColor"', 'fill="black"', $svg_content );
$data_uri = 'data:image/svg+xml;base64,' . base64_encode( $svg_content );
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The base64_encode function can potentially fail with binary data in edge cases. While SVG content is typically safe, consider using phpcs:ignore for the WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode sniff if it triggers, or wrap the call with error handling for robustness.

Suggested change
$data_uri = 'data:image/svg+xml;base64,' . base64_encode( $svg_content );
$encoded = base64_encode( $svg_content );
if ( false === $encoded ) {
$data_uri = '';
} else {
$data_uri = 'data:image/svg+xml;base64,' . $encoded;
}

Copilot uses AI. Check for mistakes.
} else {
$data_uri = '';
}
}

return $data_uri;
}
Loading
Loading