diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5dcab05 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Exclude from composer archive/install (export-ignore) +/.gitattributes export-ignore +/.gitignore export-ignore +/CLAUDE.md export-ignore +/.claude/ export-ignore +/tests/ export-ignore +/docs/ export-ignore +/.phpcs.xml.dist export-ignore +/phpunit.xml export-ignore +/phpunit.xml.dist export-ignore diff --git a/.gitignore b/.gitignore index d8fb047..fde7b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ composer.lock .phpunit.cache/ .phpunit.result.cache +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..90f88da --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# WPKit - Claude Code Configuration + +## Project Overview + +**wedevs/wp-kit** is a standalone WordPress PHP toolkit providing DataLayer, Cache, Migration, Admin Notification, and Settings components. It is a Composer library consumed by WordPress plugins (e.g., Dokan). It has no WooCommerce dependency. + +- **Namespace:** `WeDevs\WPKit\` +- **Source:** `src/` +- **Tests:** `tests/phpunit/tests/` +- **Docs:** `docs/developer-guide.md` +- **PHP:** 7.4+ + +## Architecture + +- This is a **library package**, not a plugin. It must never hardcode text domains, plugin slugs, or assume a specific consumer. +- All translatable strings must be overridable by consumers via methods or filters. Never use `__()` or `esc_html__()` directly — provide overridable methods instead. +- All WordPress hooks use the consumer-provided prefix (e.g., `option_prefix`). No library-level hook prefixes. +- Classes are abstract base classes meant to be extended by consumer plugins. + +## Coding Standards + +- Follow WordPress Coding Standards (WPCS). Run `composer phpcs` to check. +- Use WordPress PHP functions for sanitization (`sanitize_text_field`, `sanitize_key`, `absint`, etc.) and escaping (`esc_html`, `esc_attr`, etc.). +- Use tabs for indentation (WordPress standard). +- PHPDoc blocks on all public and protected methods. +- Type hints on parameters and return types where PHP 7.4 allows. + +## Key Conventions + +- **Hooks:** Use `apply_filters` and `do_action` at key extension points. Namespace hooks with the consumer's prefix property (e.g., `"{$this->option_prefix}_settings_schema"`). +- **REST Controllers:** Extend `WP_REST_Controller`. Permission callbacks must return `true` or `WP_Error` (never bare `false`). Provide overridable methods for error messages. +- **Options Storage:** Use `wp_options` with consumer-defined prefixes. Always `sanitize_key()` user-provided keys before building option names. +- **Validation:** Provide sensible default validation. Allow consumers to override via subclass methods and filters. + +## Commands + +- `composer test` — Run PHPUnit tests +- `composer phpcs` — Run PHP CodeSniffer +- `composer phpcbf` — Auto-fix coding standards +- `composer lint` — Alias for phpcs + +## Testing + +- Tests use PHPUnit 9/10 with Brain\Monkey for WordPress function mocking. +- Test namespace: `WeDevs\WPKit\Tests\` +- Test directory: `tests/phpunit/tests/` + +## File Structure + +``` +src/ +├── AdminNotification/ # Notice system (providers, manager, REST) +├── Cache/ # Caching layer +├── DataLayer/ # Models, DataStores, SQL builder +├── Migration/ # Schema migrations, background processing +└── Settings/ # Schema-driven settings REST controller +docs/ +└── developer-guide.md # Comprehensive developer documentation +tests/ +└── phpunit/ + └── tests/ # PHPUnit test files +``` diff --git a/docs/developer-guide.md b/docs/developer-guide.md index b99e11e..dde155b 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -1,6 +1,6 @@ # WPKit Developer Guide -A standalone WordPress PHP toolkit providing **DataLayer**, **Cache**, **Migration**, and **Admin Notification** components. Works with any WordPress plugin — no WooCommerce dependency required. +A standalone WordPress PHP toolkit providing **DataLayer**, **Cache**, **Migration**, **Admin Notification**, and **Settings** components. Works with any WordPress plugin — no WooCommerce dependency required. **Package:** `wedevs/wp-kit` **Namespace:** `WeDevs\WPKit\` @@ -12,7 +12,7 @@ A standalone WordPress PHP toolkit providing **DataLayer**, **Cache**, **Migrati This guide serves two audiences: -**Plugin Authors** — You are building a WordPress plugin and want to use WPKit for your data models, database migrations, caching, and admin notifications. You will extend WPKit's base classes, configure prefixes, and wire everything together in your plugin bootstrap. +**Plugin Authors** — You are building a WordPress plugin and want to use WPKit for your data models, database migrations, caching, admin notifications, and schema-driven settings pages. You will extend WPKit's base classes, configure prefixes, and wire everything together in your plugin bootstrap. **Third-Party Developers** — You are extending a plugin that already uses WPKit (e.g., building an add-on for Dokan, or customizing behavior via hooks). You need to understand the hook system, how to filter model data, modify SQL queries, inject custom notices, and work with the existing data layer without breaking things. @@ -38,6 +38,12 @@ This guide serves two audiences: - [Migration Status](#migration-status) - [Background Processing](#background-processing) - [Admin Notifications](#admin-notifications) +- [Settings](#settings) + - [Creating a Settings Controller](#creating-a-settings-controller) + - [Defining the Schema](#defining-the-schema) + - [Registering the Controller](#registering-the-controller) + - [Supported Field Variants](#supported-field-variants) + - [Overriding Translatable Messages](#overriding-translatable-messages) - [Extending a WPKit-Based Plugin (Third-Party Guide)](#extending-a-wpkit-based-plugin-third-party-guide) - [Modifying Model Data](#modifying-model-data) - [Hooking Into CRUD Lifecycle](#hooking-into-crud-lifecycle) @@ -45,6 +51,7 @@ This guide serves two audiences: - [Adding Custom Admin Notices](#adding-custom-admin-notices) - [Adding Custom Migrations](#adding-custom-migrations) - [Working With the Cache](#working-with-the-cache) + - [Extending Settings](#extending-settings) - [Hook Reference](#hook-reference) --- @@ -1364,6 +1371,204 @@ Dismissed notices are stored in the `{prefix}_dismissed_notices` option as an ar --- +## Settings + +A schema-driven REST controller for plugin settings pages, compatible with the `@wedevs/plugin-ui` `` component. Provides GET/POST endpoints that load, validate, sanitize, and persist settings to `wp_options` using a nested key structure. + +### Creating a Settings Controller + +Extend `BaseSettingsRESTController` and implement the required methods: + +```php +use WeDevs\WPKit\Settings\BaseSettingsRESTController; + +class MyPluginSettingsController extends BaseSettingsRESTController { + + public function __construct() { + parent::__construct( 'myplugin/v1', 'settings', 'myplugin_settings' ); + } + + protected function get_settings_schema(): array { + return [ + // Schema elements (see "Defining the Schema" below) + ]; + } + + protected function get_permission_error_message( string $context ): string { + if ( 'write' === $context ) { + return __( 'You do not have permission to update settings.', 'myplugin' ); + } + return __( 'You do not have permission to view settings.', 'myplugin' ); + } + + protected function get_validation_messages(): array { + return [ + 'number' => __( 'Must be a numeric value.', 'myplugin' ), + 'switch' => __( 'Must be "on" or "off".', 'myplugin' ), + 'invalid_option' => __( 'Invalid option selected.', 'myplugin' ), + 'must_be_array' => __( 'Must be an array.', 'myplugin' ), + 'invalid_options' => __( 'Contains invalid options.', 'myplugin' ), + 'color_picker' => __( 'Must be a valid hex color (e.g. #ff0000).', 'myplugin' ), + 'must_be_object' => __( 'Must be an object.', 'myplugin' ), + ]; + } +} +``` + +The constructor takes three arguments: + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `$namespace` | REST API namespace | `'myplugin/v1'` | +| `$rest_base` | Route base path | `'settings'` | +| `$option_prefix` | `wp_options` key prefix | `'myplugin_settings'` | + +Settings are stored as `{option_prefix}_{page_id}` in `wp_options` (e.g., `myplugin_settings_general`). + +### Defining the Schema + +The schema is a flat array of element objects that the `` component renders. Each element has a `type` (`page`, `tab`, `section`, `field`, etc.) and fields reference their parents via `page_id`, `tab_id`, `section_id`, etc. + +```php +protected function get_settings_schema(): array { + return [ + [ + 'type' => 'page', + 'id' => 'general', + 'title' => 'General', + ], + [ + 'type' => 'section', + 'id' => 'store', + 'page_id' => 'general', + 'title' => 'Store Settings', + ], + [ + 'type' => 'field', + 'id' => 'store_name', + 'variant' => 'text', + 'page_id' => 'general', + 'section_id' => 'store', + 'title' => 'Store Name', + 'default' => '', + ], + [ + 'type' => 'field', + 'id' => 'enable_tax', + 'variant' => 'switch', + 'page_id' => 'general', + 'section_id' => 'store', + 'title' => 'Enable Tax', + 'default' => 'off', + ], + [ + 'type' => 'field', + 'id' => 'currency', + 'variant' => 'select', + 'page_id' => 'general', + 'section_id' => 'store', + 'title' => 'Currency', + 'default' => 'USD', + 'options' => [ + [ 'label' => 'US Dollar', 'value' => 'USD' ], + [ 'label' => 'Euro', 'value' => 'EUR' ], + ], + ], + ]; +} +``` + +Values are stored in a nested structure derived from the field's parent IDs. For the schema above, the `wp_options` entry `myplugin_settings_general` would contain: + +```php +[ + 'store' => [ + 'store_name' => 'My Store', + 'enable_tax' => 'on', + 'currency' => 'USD', + ], +] +``` + +### Registering the Controller + +```php +add_action( 'rest_api_init', function () { + $controller = new MyPluginSettingsController(); + $controller->register_routes(); +} ); +``` + +**Endpoints:** + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/wp-json/myplugin/v1/settings` | GET | Returns schema with current values populated in `default` props | +| `/wp-json/myplugin/v1/settings` | POST | Saves values; requires `scopeId` (page ID) and `values` (nested object) | + +**POST request body example:** + +```json +{ + "scopeId": "general", + "values": { + "store": { + "store_name": "My Store", + "enable_tax": "on", + "currency": "EUR" + } + } +} +``` + +### Supported Field Variants + +The controller provides built-in validation and sanitization for these variants: + +| Variant | Value Type | Sanitization | Validation | +|---------|-----------|--------------|------------| +| `text` | string | `sanitize_text_field()` | — | +| `number` | numeric | `intval()` | `is_numeric()` | +| `switch` | `"on"` / `"off"` | Whitelist check | Must be `"on"` or `"off"` | +| `select` | string | `sanitize_text_field()` | Must be in `options[].value` | +| `radio_capsule` | string | `sanitize_text_field()` | Must be in `options[].value` | +| `customize_radio` | string | `sanitize_text_field()` | Must be in `options[].value` | +| `multicheck` | array | `array_map( 'sanitize_text_field' )` | All values must be in `options[].value` | +| `textarea` | string | `sanitize_textarea_field()` | — | +| `color_picker` | string | `sanitize_hex_color()` | Must match `#rrggbb` | +| `combine_input` | object | `array_map( 'sanitize_text_field' )` | Must be an array | +| `html` | — | Skipped (display-only) | — | +| `base_field_label` | — | Skipped (display-only) | — | + +### Overriding Translatable Messages + +The base class provides English fallback strings. Subclasses **must** override `get_permission_error_message()` and `get_validation_messages()` with the plugin's text domain: + +```php +protected function get_permission_error_message( string $context ): string { + if ( 'write' === $context ) { + return __( 'You do not have permission to update settings.', 'myplugin' ); + } + return __( 'You do not have permission to view settings.', 'myplugin' ); +} + +protected function get_validation_messages(): array { + return [ + 'number' => __( 'Must be a numeric value.', 'myplugin' ), + 'switch' => __( 'Must be "on" or "off".', 'myplugin' ), + 'invalid_option' => __( 'Invalid option selected.', 'myplugin' ), + 'must_be_array' => __( 'Must be an array.', 'myplugin' ), + 'invalid_options' => __( 'Contains invalid options.', 'myplugin' ), + 'color_picker' => __( 'Must be a valid hex color (e.g. #ff0000).', 'myplugin' ), + 'must_be_object' => __( 'Must be an object.', 'myplugin' ), + ]; +} +``` + +Validation messages can also be filtered at runtime via the `{prefix}_settings_validation_messages` filter. + +--- + ## Extending a WPKit-Based Plugin (Third-Party Guide) This section is for developers building add-ons or customizations for a plugin that uses WPKit. All examples assume a host plugin with prefix `dokan` — replace with the actual plugin's prefix. @@ -1641,6 +1846,87 @@ if ( $store && $store->get_cache() ) { } ``` +### Extending Settings + +If the host plugin uses `BaseSettingsRESTController`, you can extend its settings via filters. All hooks are prefixed with the controller's `option_prefix` (e.g., `dokan_settings`). + +#### Adding Fields to the Schema + +```php +// Add a custom field to the settings schema +add_filter( 'dokan_settings_settings_schema', function ( $schema, $request ) { + $schema[] = [ + 'type' => 'field', + 'id' => 'my_addon_feature', + 'variant' => 'switch', + 'page_id' => 'general', + 'section_id' => 'store', + 'title' => 'My Add-on Feature', + 'default' => 'off', + ]; + return $schema; +}, 10, 2 ); +``` + +#### Modifying Values Before Save + +```php +// Enforce a business rule before saving +add_filter( 'dokan_settings_settings_before_save', function ( $sanitized, $values, $scope_id ) { + if ( $scope_id === 'general' && isset( $sanitized['store']['commission'] ) ) { + $sanitized['store']['commission'] = min( $sanitized['store']['commission'], 50 ); + } + return $sanitized; +}, 10, 3 ); +``` + +#### Reacting After Save + +```php +// Clear a cache or sync when settings change +add_action( 'dokan_settings_settings_after_save', function ( $merged, $sanitized, $scope_id ) { + if ( $scope_id === 'payment' ) { + delete_transient( 'my_addon_payment_config' ); + } +}, 10, 3 ); +``` + +#### Custom Validation + +```php +// Add validation for a specific field +add_filter( 'dokan_settings_settings_validate_field', function ( $error, $field, $value ) { + if ( $field['id'] === 'api_key' && strlen( $value ) < 20 ) { + return 'API key must be at least 20 characters.'; + } + return $error; +}, 10, 3 ); +``` + +#### Custom Sanitization + +```php +// Override sanitization for a specific field +add_filter( 'dokan_settings_settings_sanitize_field', function ( $clean, $field, $value ) { + if ( $field['id'] === 'custom_css' ) { + return wp_strip_all_tags( $value ); + } + return $clean; +}, 10, 3 ); +``` + +#### Changing Permission Requirements + +```php +// Allow editors to view settings but not save +add_filter( 'dokan_settings_settings_capability', function ( $capability, $context, $request ) { + if ( $context === 'read' ) { + return 'edit_pages'; + } + return $capability; +}, 10, 3 ); +``` + --- ## Hook Reference @@ -1702,6 +1988,36 @@ The `{prefix}` is the plugin-level prefix set via `set_filter_prefix()` (e.g., ` | `{prefix}/v1/migration/status` | GET | `update_plugins` | Returns migration log, summary, and status | | `{prefix}/v1/migration/upgrade` | POST | `update_plugins` | Triggers admin upgrade | +### Settings Hooks + +The `{prefix}` is the `option_prefix` passed to the controller's constructor (e.g., `dokan_settings`). + +| Hook | Type | Parameters | Description | +|------|------|------------|-------------| +| `{prefix}_settings_capability` | filter | `$capability, $context, $request` | Customize required capability (`$context` is `'read'` or `'write'`) | +| `{prefix}_settings_schema` | filter | `$schema, $request` | Modify schema before values are loaded | +| `{prefix}_settings_get_response` | filter | `$response_data, $request` | Modify full GET response (schema + values) | +| `{prefix}_settings_loaded_values` | filter | `$values, $schema` | Modify loaded values after defaults applied | +| `{prefix}_settings_validate_field` | filter | `$error, $field, $value` | Override validation for a single field | +| `{prefix}_settings_validation_errors` | filter | `$errors, $values, $scope_id` | Add/remove validation errors before rejecting | +| `{prefix}_settings_validation_messages` | filter | `$messages` | Translate/customize error message strings | +| `{prefix}_settings_sanitize_field` | filter | `$clean, $field, $value` | Override sanitization for a single field | +| `{prefix}_settings_before_save` | filter | `$sanitized, $values, $scope_id` | Modify sanitized values before DB write | +| `{prefix}_settings_after_save` | action | `$merged, $sanitized, $scope_id` | React after settings are saved | + +#### Settings REST Endpoints + +| Route | Method | Permission | Description | +|-------|--------|------------|-------------| +| `{namespace}/{rest_base}` | GET | `manage_options` | Returns schema with current values | +| `{namespace}/{rest_base}` | POST | `manage_options` | Saves values for a `scopeId` (page ID) | + +#### Settings WordPress Options + +| Option Key | Description | +|------------|-------------| +| `{option_prefix}_{page_id}` | Nested settings values for a page (e.g., `myplugin_settings_general`) | + ### Notification Hooks | Hook | Type | Parameters | Description | diff --git a/src/Settings/BaseSettingsRESTController.php b/src/Settings/BaseSettingsRESTController.php new file mode 100644 index 0000000..5f1c48c --- /dev/null +++ b/src/Settings/BaseSettingsRESTController.php @@ -0,0 +1,658 @@ + component. + * + * Subclasses must implement get_validation_messages, get_permission_error_message() and get_settings_schema() and pass + * $namespace, $rest_base, and $option_prefix to the constructor. + * + * Endpoints: + * GET /{namespace}/{rest_base} — returns schema + nested values + * POST /{namespace}/{rest_base} — saves nested values to wp_options + */ +abstract class BaseSettingsRESTController extends \WP_REST_Controller { + + /** + * WordPress options key prefix (e.g. "wpkit_tasks"). + * + * @var string + */ + protected $option_prefix; + + /** + * Constructor. + * + * @param string $namespace REST API namespace (e.g. "myplugin/v1"). + * @param string $rest_base REST route base (e.g. "settings"). + * @param string $option_prefix WordPress options key prefix. + */ + public function __construct( string $namespace, string $rest_base, string $option_prefix ) { + $this->namespace = $namespace; + $this->rest_base = $rest_base; + $this->option_prefix = $option_prefix; + } + + /** + * Return the settings schema array. + * + * @return array[] Flat array of SettingsElement objects. + */ + abstract protected function get_settings_schema(): array; + + /** + * Register REST routes. + */ + public function register_routes(): void { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_items' ], + 'permission_callback' => [ $this, 'get_items_permissions_check' ], + ], + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'create_item' ], + 'permission_callback' => [ $this, 'create_item_permissions_check' ], + ], + ] + ); + } + + /** + * Permission check for GET — manage_options required. + * + * @param \WP_REST_Request $request Request object. + * + * @return true|\WP_Error + */ + public function get_items_permissions_check( $request ) { + $capability = apply_filters( "{$this->option_prefix}_settings_capability", 'manage_options', 'read', $request ); + + if ( ! current_user_can( $capability ) ) { + return new \WP_Error( + 'rest_forbidden', + $this->get_permission_error_message( 'read' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + + return true; + } + + /** + * Permission check for POST — manage_options required. + * + * @param \WP_REST_Request $request Request object. + * + * @return true|\WP_Error + */ + public function create_item_permissions_check( $request ) { + $capability = apply_filters( "{$this->option_prefix}_settings_capability", 'manage_options', 'write', $request ); + + if ( ! current_user_can( $capability ) ) { + return new \WP_Error( + 'rest_forbidden', + $this->get_permission_error_message( 'write' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + + return true; + } + + /** + * Get the permission error message for a given context. + * + * IMPORTANT: Subclasses MUST override this method to return properly + * translated strings using the plugin's own text domain. The base + * implementation returns untranslated English strings as fallback only. + * + * Example: + * + * protected function get_permission_error_message( string $context ): string { + * if ( 'write' === $context ) { + * return __( 'You do not have permission to update settings.', 'your-text-domain' ); + * } + * return __( 'You do not have permission to view settings.', 'your-text-domain' ); + * } + * + * @param string $context 'read' or 'write'. + * + * @return string + */ + protected function get_permission_error_message( string $context ): string { + if ( 'write' === $context ) { + return 'You do not have permission to update settings.'; + } + + return 'You do not have permission to view settings.'; + } + + /** + * GET handler — return schema and current nested values. + * + * @param \WP_REST_Request $request Request object. + * + * @return \WP_REST_Response + */ + public function get_items( $request ) { + $schema = $this->get_settings_schema(); + + /** + * Filter the settings schema before loading values. + * + * @param array $schema Settings schema elements. + * @param \WP_REST_Request $request Request object. + */ + $schema = apply_filters( "{$this->option_prefix}_settings_schema", $schema, $request ); + + $values = $this->load_values( $schema ); + + // Set default prop on each field from stored nested values. + foreach ( $schema as &$element ) { + if ( 'field' !== $element['type'] ) { + continue; + } + + $path = $this->get_field_path( $element ); + $value = $this->get_nested_value( $values, $path ); + + if ( null !== $value ) { + $element['default'] = $value; + } + } + unset( $element ); + + $response_data = [ + 'schema' => $schema, + 'values' => $values, + ]; + + /** + * Filter the GET settings response data. + * + * @param array $response_data Response containing schema and values. + * @param \WP_REST_Request $request Request object. + */ + $response_data = apply_filters( "{$this->option_prefix}_settings_get_response", $response_data, $request ); + + return new \WP_REST_Response( $response_data ); + } + + /** + * POST handler — save nested values. + * + * @param \WP_REST_Request $request Request object. + * + * @return \WP_REST_Response + */ + public function create_item( $request ) { + $scope_id = sanitize_key( $request->get_param( 'scopeId' ) ?? '' ); + $values = $request->get_param( 'values' ); + + if ( ! is_array( $values ) || empty( $scope_id ) ) { + return new \WP_REST_Response( + [ 'errors' => [ 'values' => 'Invalid values format.' ] ], + 400 + ); + } + + $schema = $this->get_settings_schema(); + $page_ids = $this->get_page_ids( $schema ); + + if ( ! in_array( $scope_id, $page_ids, true ) ) { + return new \WP_REST_Response( + [ 'errors' => [ 'scopeId' => 'Invalid scope ID.' ] ], + 400 + ); + } + + $fields = $this->get_fields_for_page( $schema, $scope_id ); + $errors = []; + $sanitized = []; + + foreach ( $fields as $field ) { + $path = $this->get_field_path( $field ); + $value = $this->get_nested_value( $values, $path ); + + if ( null === $value ) { + continue; + } + + $error = $this->validate_field( $field, $value ); + + if ( $error ) { + $errors[ $field['id'] ] = $error; + continue; + } + + $clean = $this->sanitize_field( $field, $value ); + + /** + * Filter a single field's sanitized value. + * + * @param mixed $clean Sanitized value. + * @param array $field Field definition. + * @param mixed $value Original raw value. + */ + $clean = apply_filters( "{$this->option_prefix}_settings_sanitize_field", $clean, $field, $value ); + + if ( null === $clean ) { + continue; + } + + $this->set_nested_value( $sanitized, $path, $clean ); + } + + /** + * Filter validation errors before returning. + * + * @param array $errors Validation errors keyed by field ID. + * @param array $values Submitted values. + * @param string $scope_id Page/scope ID being saved. + */ + $errors = apply_filters( "{$this->option_prefix}_settings_validation_errors", $errors, $values, $scope_id ); + + if ( ! empty( $errors ) ) { + return new \WP_REST_Response( [ 'errors' => $errors ], 400 ); + } + + /** + * Filter sanitized values before saving to the database. + * + * @param array $sanitized Sanitized values to be saved. + * @param array $values Original submitted values. + * @param string $scope_id Page/scope ID being saved. + */ + $sanitized = apply_filters( "{$this->option_prefix}_settings_before_save", $sanitized, $values, $scope_id ); + + $option_key = $this->option_prefix . '_' . $scope_id; + $existing_values = get_option( $option_key, [] ); + + if ( ! is_array( $existing_values ) ) { + $existing_values = []; + } + + $merged = $this->array_merge_deep( $existing_values, $sanitized ); + update_option( $option_key, $merged ); + + /** + * Fires after settings have been saved. + * + * @param array $merged Final merged values saved to the database. + * @param array $sanitized Sanitized values that were applied. + * @param string $scope_id Page/scope ID that was saved. + */ + do_action( "{$this->option_prefix}_settings_after_save", $merged, $sanitized, $scope_id ); + + return new \WP_REST_Response( + [ + 'success' => true, + 'values' => $merged, + ] + ); + } + + /** + * Validate a field value. + * + * Override in subclass for plugin-specific validation. + * + * @param array $field Field definition. + * @param mixed $value Submitted value. + * + * @return string|null Error message or null if valid. + */ + protected function validate_field( array $field, $value ): ?string { + $variant = $field['variant'] ?? 'text'; + $messages = $this->get_validation_messages(); + $error = null; + + switch ( $variant ) { + case 'number': + if ( ! is_numeric( $value ) ) { + $error = $messages['number']; + } + break; + + case 'switch': + if ( ! in_array( $value, [ 'on', 'off' ], true ) ) { + $error = $messages['switch']; + } + break; + + case 'select': + case 'radio_capsule': + case 'customize_radio': + $options = array_column( $field['options'] ?? [], 'value' ); + if ( ! empty( $options ) && ! in_array( $value, $options, true ) ) { + $error = $messages['invalid_option']; + } + break; + + case 'multicheck': + if ( ! is_array( $value ) ) { + $error = $messages['must_be_array']; + break; + } + $options = array_column( $field['options'] ?? [], 'value' ); + if ( ! empty( $options ) ) { + $invalid = array_diff( $value, $options ); + if ( ! empty( $invalid ) ) { + $error = $messages['invalid_options']; + } + } + break; + + case 'color_picker': + if ( ! is_string( $value ) || ! preg_match( '/^#[0-9a-fA-F]{6}$/', $value ) ) { + $error = $messages['color_picker']; + } + break; + + case 'combine_input': + if ( ! is_array( $value ) ) { + $error = $messages['must_be_object']; + } + break; + } + + /** + * Filter the validation error for a single field. + * + * @param string|null $error Validation error message, or null if valid. + * @param array $field Field definition. + * @param mixed $value Submitted value. + */ + return apply_filters( "{$this->option_prefix}_settings_validate_field", $error, $field, $value ); + } + + /** + * Get validation error messages. + * + * IMPORTANT: Subclasses MUST override this method to return properly + * translated strings using the plugin's own text domain. The base + * implementation returns untranslated English strings as fallback only. + * + * Example: + * + * protected function get_validation_messages(): array { + * return [ + * 'number' => __( 'Must be a numeric value.', 'your-text-domain' ), + * 'switch' => __( 'Must be "on" or "off".', 'your-text-domain' ), + * 'invalid_option' => __( 'Invalid option selected.', 'your-text-domain' ), + * 'must_be_array' => __( 'Must be an array.', 'your-text-domain' ), + * 'invalid_options' => __( 'Contains invalid options.', 'your-text-domain' ), + * 'color_picker' => __( 'Must be a valid hex color (e.g. #ff0000).', 'your-text-domain' ), + * 'must_be_object' => __( 'Must be an object.', 'your-text-domain' ), + * ]; + * } + * + * @return array Keyed error messages. + */ + protected function get_validation_messages(): array { + $messages = [ + 'number' => 'Must be a numeric value.', + 'switch' => 'Must be "on" or "off".', + 'invalid_option' => 'Invalid option selected.', + 'must_be_array' => 'Must be an array.', + 'invalid_options' => 'Contains invalid options.', + 'color_picker' => 'Must be a valid hex color (e.g. #ff0000).', + 'must_be_object' => 'Must be an object.', + ]; + + /** + * Filter validation error messages. + * + * @param array $messages Keyed error messages. + */ + return apply_filters( "{$this->option_prefix}_settings_validation_messages", $messages ); + } + + /** + * Sanitize a field value based on variant. + * + * @param array $field Field definition. + * @param mixed $value Raw value. + * + * @return mixed Sanitized value. + */ + protected function sanitize_field( array $field, $value ) { + $variant = $field['variant'] ?? 'text'; + + switch ( $variant ) { + case 'number': + return is_float( $value + 0 ) ? floatval( $value ) : intval( $value ); + + case 'switch': + return in_array( $value, [ 'on', 'off' ], true ) ? $value : 'off'; + + case 'select': + case 'radio_capsule': + case 'customize_radio': + return sanitize_text_field( $value ); + + case 'multicheck': + if ( ! is_array( $value ) ) { + return []; + } + return array_map( 'sanitize_text_field', $value ); + + case 'textarea': + return sanitize_textarea_field( $value ); + + case 'color_picker': + $hex = sanitize_hex_color( $value ); + return $hex ? $hex : ''; + + case 'combine_input': + if ( ! is_array( $value ) ) { + return []; + } + return array_map( 'sanitize_text_field', $value ); + + case 'html': + case 'base_field_label': + return null; + + default: + return sanitize_text_field( $value ); + } + } + + /** + * Load current nested values from wp_options, with defaults filled in. + * + * @param array $schema Schema array. + * + * @return array Nested values. + */ + protected function load_values( array $schema ): array { + $page_ids = $this->get_page_ids( $schema ); + $fields = $this->get_fields( $schema ); + $values = []; + + foreach ( $page_ids as $page_id ) { + $option_key = $this->option_prefix . '_' . $page_id; + $stored_values = get_option( $option_key, [] ); + + if ( is_array( $stored_values ) ) { + $values = $this->array_merge_deep( $values, $stored_values ); + } + } + + foreach ( $fields as $field ) { + $path = $this->get_field_path( $field ); + $current = $this->get_nested_value( $values, $path ); + + if ( null === $current ) { + $this->set_nested_value( $values, $path, $field['default'] ?? '' ); + } + } + + /** + * Filter loaded settings values (with defaults applied). + * + * @param array $values Nested settings values. + * @param array $schema Settings schema. + */ + return apply_filters( "{$this->option_prefix}_settings_loaded_values", $values, $schema ); + } + + /** + * Build the nested path array for a field element. + * + * @param array $element Field element. + * + * @return string[] + */ + protected function get_field_path( array $element ): array { + $parts = []; + $parent_keys = [ 'subpage_id', 'tab_id', 'section_id', 'subsection_id', 'field_group_id' ]; + + foreach ( $parent_keys as $pk ) { + if ( ! empty( $element[ $pk ] ) ) { + $parts[] = $element[ $pk ]; + } + } + + $parts[] = $element['id']; + + return $parts; + } + + /** + * Get a value from a nested array by path. + * + * @param array $data Nested array. + * @param string[] $path Path parts. + * + * @return mixed|null + */ + protected function get_nested_value( array $data, array $path ) { + $cursor = $data; + + foreach ( $path as $key ) { + if ( ! is_array( $cursor ) || ! array_key_exists( $key, $cursor ) ) { + return null; + } + $cursor = $cursor[ $key ]; + } + + return $cursor; + } + + /** + * Set a value in a nested array by path. + * + * @param array $data Nested array (by reference). + * @param string[] $path Path parts. + * @param mixed $value Value to set. + */ + protected function set_nested_value( array &$data, array $path, $value ): void { + $cursor = &$data; + + for ( $i = 0, $len = count( $path ); $i < $len - 1; $i++ ) { + if ( ! isset( $cursor[ $path[ $i ] ] ) || ! is_array( $cursor[ $path[ $i ] ] ) ) { + $cursor[ $path[ $i ] ] = []; + } + $cursor = &$cursor[ $path[ $i ] ]; + } + + $cursor[ end( $path ) ] = $value; + } + + /** + * Deep merge two nested arrays (second wins on scalar conflicts). + * + * @param array $base Base array. + * @param array $overlay Overlay array. + * + * @return array + */ + protected function array_merge_deep( array $base, array $overlay ): array { + foreach ( $overlay as $key => $value ) { + if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) ) { + $base[ $key ] = $this->array_merge_deep( $base[ $key ], $value ); + } else { + $base[ $key ] = $value; + } + } + + return $base; + } + + /** + * Get all page IDs from schema. + * + * @param array $schema Schema array. + * + * @return string[] + */ + protected function get_page_ids( array $schema ): array { + $ids = []; + + foreach ( $schema as $element ) { + if ( 'page' === $element['type'] ) { + $ids[] = $element['id']; + } + } + + return $ids; + } + + /** + * Get all field elements from schema. + * + * @param array $schema Schema array. + * + * @return array[] + */ + protected function get_fields( array $schema ): array { + $fields = []; + + foreach ( $schema as $element ) { + if ( 'field' === $element['type'] ) { + $fields[] = $element; + } + } + + return $fields; + } + + /** + * Get field elements for a specific page. + * + * @param array $schema Schema array. + * @param string $page_id Page ID. + * + * @return array[] + */ + protected function get_fields_for_page( array $schema, string $page_id ): array { + $fields = []; + + foreach ( $schema as $element ) { + if ( 'field' === $element['type'] + && isset( $element['page_id'] ) + && $element['page_id'] === $page_id + ) { + $fields[] = $element; + } + } + + return $fields; + } +}