breznflow/includes/Features/ThemeImporter.php
Michael fd83e4810b BreznFlow 1.0.0 — WordPress.org submission
Initial public release of BreznFlow, an n8n workflow renderer for WordPress.
Fully PHPCS-compliant (WordPress Coding Standards), security-hardened,
and ready for WordPress.org plugin review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:27:36 +00:00

265 lines
6.8 KiB
PHP

<?php
/**
* Validates and stores custom theme definitions.
*
* @package BreznFlow
* @since 1.0.0
*/
namespace BreznFlow\Features;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles import, validation and storage of custom theme JSON files.
*
* @since 1.0.0
*/
class ThemeImporter {
/**
* All 41 allowed token names. Any key outside this list is rejected during import.
*
* @var string[]
*/
const ALLOWED_TOKENS = array(
'canvas_bg',
'node_bg',
'node_text',
'node_sub',
'node_border',
'connection',
'connection_hover',
'toolbar_bg',
'toolbar_text',
'toolbar_border',
'panel_bg',
'panel_text',
'panel_border',
'btn_bg',
'btn_text',
'btn_border',
'btn_hover_bg',
'action_bar_bg',
'action_bar_border',
'modal_overlay_bg',
'modal_bg',
'modal_border',
'modal_title',
'modal_text',
'modal_sub',
'modal_close',
'modal_secondary_bg',
'modal_secondary_border',
'modal_code_bg',
'tooltip_bg',
'tooltip_text',
'fullscreen_overlay_bg',
'minimap_bg',
'minimap_border',
'color_trigger',
'color_http',
'color_code',
'color_logic',
'color_database',
'color_ai',
'color_fallback',
);
/** IDs reserved for built-in themes — custom themes must not use these. */
const BUILTIN_IDS = array( 'dark', 'light', 'minimal', 'tech', 'brezn' );
/**
* Validates a decoded theme array.
*
* @param array $data Decoded JSON as PHP array.
* @return true|\WP_Error
*/
public static function validate( array $data ) {
// Required top-level fields.
foreach ( array( 'name', 'id', 'version', 'tokens' ) as $field ) {
if ( ! isset( $data[ $field ] ) ) {
return new \WP_Error(
'missing_field',
/* translators: %s: field name */
sprintf( __( 'Missing required field: %s', 'breznflow' ), $field )
);
}
}
// No extra top-level fields allowed.
$extra = array_diff( array_keys( $data ), array( 'name', 'id', 'version', 'tokens' ) );
if ( ! empty( $extra ) ) {
return new \WP_Error(
'extra_fields',
/* translators: %s: comma-separated field names */
sprintf( __( 'Unexpected fields: %s', 'breznflow' ), implode( ', ', $extra ) )
);
}
// id: must sanitize cleanly and not collide with built-ins.
$id = sanitize_key( (string) $data['id'] );
if ( '' === $id || $id !== (string) $data['id'] ) {
return new \WP_Error(
'invalid_id',
__( 'Theme ID must contain only lowercase letters, numbers, and hyphens.', 'breznflow' )
);
}
if ( in_array( $id, self::BUILTIN_IDS, true ) ) {
return new \WP_Error(
'reserved_id',
/* translators: %s: theme ID */
sprintf( __( 'Theme ID "%s" is reserved for built-in themes.', 'breznflow' ), $id )
);
}
// name: non-empty string, max 80 chars.
$name = sanitize_text_field( (string) $data['name'] );
if ( '' === $name ) {
return new \WP_Error( 'invalid_name', __( 'Theme name must not be empty.', 'breznflow' ) );
}
if ( mb_strlen( $name ) > 80 ) {
return new \WP_Error( 'invalid_name', __( 'Theme name must be 80 characters or fewer.', 'breznflow' ) );
}
// version: must be an integer.
if ( ! is_int( $data['version'] ) && ! ctype_digit( (string) $data['version'] ) ) {
return new \WP_Error( 'invalid_version', __( 'Theme version must be an integer.', 'breznflow' ) );
}
// tokens: must be an array.
if ( ! is_array( $data['tokens'] ) ) {
return new \WP_Error( 'invalid_tokens', __( 'Tokens must be an object.', 'breznflow' ) );
}
// Tokens: no extra keys.
$token_keys = array_keys( $data['tokens'] );
$extra_tokens = array_diff( $token_keys, self::ALLOWED_TOKENS );
if ( ! empty( $extra_tokens ) ) {
return new \WP_Error(
'unknown_tokens',
/* translators: %s: comma-separated token names */
sprintf( __( 'Unknown tokens: %s', 'breznflow' ), implode( ', ', $extra_tokens ) )
);
}
// Tokens: all 41 must be present.
$missing = array_diff( self::ALLOWED_TOKENS, $token_keys );
if ( ! empty( $missing ) ) {
return new \WP_Error(
'missing_tokens',
/* translators: %s: comma-separated token names */
sprintf( __( 'Missing tokens: %s', 'breznflow' ), implode( ', ', $missing ) )
);
}
// Each token value must be a safe color expression.
foreach ( $data['tokens'] as $token => $value ) {
if ( ! self::validate_color_value( (string) $value ) ) {
return new \WP_Error(
'invalid_color',
/* translators: 1: token name, 2: value */
sprintf( __( 'Invalid color value for "%1$s": %2$s', 'breznflow' ), $token, $value )
);
}
}
return true;
}
/**
* Validates and stores a theme in wp_options.
* If a theme with the same ID already exists it is replaced.
*
* @param array $data Decoded JSON as PHP array.
* @return true|\WP_Error
*/
public static function import( array $data ) {
$result = self::validate( $data );
if ( is_wp_error( $result ) ) {
return $result;
}
$themes = get_option( 'breznflow_custom_themes', array() );
// Remove existing entry with the same ID.
foreach ( $themes as $i => $t ) {
if ( sanitize_key( $data['id'] ) === $t['id'] ) {
array_splice( $themes, $i, 1 );
break;
}
}
$themes[] = array(
'id' => sanitize_key( $data['id'] ),
'name' => sanitize_text_field( $data['name'] ),
'version' => (int) $data['version'],
'tokens' => $data['tokens'],
);
update_option( 'breznflow_custom_themes', $themes );
return true;
}
/**
* Deletes a custom theme by ID.
*
* @param string $id Theme ID.
* @return bool True if deleted, false if not found.
*/
public static function delete( string $id ): bool {
$id = sanitize_key( $id );
$themes = get_option( 'breznflow_custom_themes', array() );
$new = array_values( array_filter( $themes, fn( $t ) => $t['id'] !== $id ) );
if ( count( $new ) === count( $themes ) ) {
return false;
}
update_option( 'breznflow_custom_themes', $new );
return true;
}
/**
* Returns all stored custom themes.
*
* @return array[]
*/
public static function get_all(): array {
return get_option( 'breznflow_custom_themes', array() );
}
/**
* Validates a single CSS color value.
* Accepts: #rgb, #rrggbb, #rrggbbaa, rgb(...), rgba(...)
* Rejects: url(...), @import, semicolons, curly braces, etc.
*
* @param string $value Raw token value.
* @return bool
*/
private static function validate_color_value( string $value ): bool {
$value = trim( $value );
// Hex: #rgb, #rrggbb, #rrggbbaa.
if ( preg_match( '/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}([0-9a-fA-F]{2})?)?$/', $value ) ) {
return true;
}
// rgb(r, g, b).
if ( preg_match( '/^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/', $value ) ) {
return true;
}
// rgba(r, g, b, a) — alpha must be 0..1 decimal.
if ( preg_match(
'/^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\)$/',
$value
) ) {
return true;
}
return false;
}
}