settings` with a `Settings_Assets_Interface` instance. */ abstract class Base_Module { /** @var string Admin post type this module owns, or '' for none. */ const ADMIN_POST_TYPE = ''; /** @var array Module options, loaded from static::OPTION_NAME. */ protected $options; /** @var Settings_Assets_Interface Settings object populated by init(). */ protected $settings; /** @var array Per-module-ID cached admin settings URL. */ private static array $module_admin_urls = []; /** * Load options, self-register the admin post type, and run init(). * * @throws \LogicException When the subclass's init() did not populate * $this->settings with a Settings_Assets_Interface. */ final public function __construct() { $this->options = get_option( static::OPTION_NAME, $this->get_default_options() ); if ( '' !== static::ADMIN_POST_TYPE ) { Module_Registry::register_admin_post_type( static::MODULE_ID, static::ADMIN_POST_TYPE ); } $this->init(); if ( ! $this->settings instanceof Settings_Assets_Interface ) { throw new \LogicException( static::class . ' settings must implement Settings_Assets_Interface.' ); } add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_settings_assets' ] ); } /** * Resolve and cache this module's canonical admin settings-page URL. * * Computed lazily, on first call, rather than in the constructor: * Base_Module instances are constructed during the owning Section's * plugins_loaded-time auto-boot, and Module_Registry's module lookup * reaches the localized module catalogue (translated names/descriptions) * — calling it that early triggers WordPress's "translation loading * triggered too early" notice. Nothing needs this URL before a settings * page actually renders, well after init, so resolving it here instead * of in the constructor sidesteps the timing hazard entirely. * * @param array $query_args URL parameter. * @return string */ public static function get_module_admin_url( array $query_args = [] ): string { if ( ! isset( self::$module_admin_urls[ static::MODULE_ID ] ) ) { self::$module_admin_urls[ static::MODULE_ID ] = Module_Registry::get_module_url( static::MODULE_ID ); } $admin_url = self::$module_admin_urls[ static::MODULE_ID ]; return empty( $query_args ) ? $admin_url : add_query_arg( $query_args, $admin_url ); } /** * Default options used when static::OPTION_NAME has no stored value yet. * * @return array */ protected function get_default_options() { return []; } /** * Subclass hook run by the constructor: register hooks and populate * $this->settings with a Settings_Assets_Interface instance. * * @return void */ abstract protected function init(): void; /** * Return this module's initialized settings object for Section rendering. * * @return Settings_Assets_Interface */ final public function get_settings(): Settings_Assets_Interface { return $this->settings; } /** * admin_enqueue_scripts callback: enqueue settings assets only on this * module's own settings screen. * * @param string $hook_suffix Current admin page hook suffix. * * @return void */ final public function enqueue_settings_assets( string $hook_suffix ): void { if ( $this->is_settings_screen( $hook_suffix ) ) { $this->enqueue_section_settings_assets( $hook_suffix ); } } /** * Enqueue settings assets when the module is composed inside a Section. * * @param string $hook_suffix Current admin page hook suffix. * * @return void */ final public function enqueue_section_settings_assets( string $hook_suffix ): void { $this->settings->enqueue_settings_assets( $hook_suffix ); } /** * Whether the current admin screen belongs to this module. * * @param string $hook_suffix Current admin page hook suffix. * * @return bool */ protected function is_settings_screen( string $hook_suffix ): bool { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; return Module_Registry::is_admin_screen_for_module( static::MODULE_ID, $screen ); } /** * Echo (or return) hidden fields for a URL's query args, for use * inside a method="get" form — a GET submit discards whatever query * string is in the form's action attribute, so anything the URL needs to * carry (page, module, tab, …) must be restated as hidden fields instead. * * @param string $module_url Admin URL whose query args should be preserved. * @param bool $echo Whether to echo the markup. Default true. * * @return string */ public static function module_form_fields( string $module_url, bool $echo = true ): string { $query_args = []; wp_parse_str( (string) wp_parse_url( $module_url, PHP_URL_QUERY ), $query_args ); $fields = ''; foreach ( $query_args as $key => $value ) { if ( is_array( $value ) ) { foreach ( $value as $single_value ) { $fields .= sprintf( '' . "\n", esc_attr( $key ), esc_attr( $single_value ) ); } continue; } $fields .= sprintf( '' . "\n", esc_attr( $key ), esc_attr( $value ) ); } if ( $echo ) { echo $fields; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built entirely from esc_attr()'d values above. } return $fields; } }