. * Shares the exact jump-and-focus logic the widget panel's own Skip to * Content control uses, via window.WelcomaWidget.skipToMain() — but is * reachable by the very first Tab press, without opening the widget. * Fixes Issue N5b. */ public function render_skip_link() { if ( ! $this->should_load() ) { return; } ?> should_load() ) { return; } echo '' . "\n"; } /** * Print the widget landmark server-side so it exists in the raw HTML * before JavaScript runs. buildUI() reuses this node. */ public function render_widget_landmark() { if ( ! $this->should_load() ) { return; } $settings = Welcoma_Settings::get(); $position = ( isset( $settings['position'] ) && 'left' === $settings['position'] ) ? 'left' : 'right'; $title = Welcoma_Translator::get_string( 'accessibility_title', Welcoma_Translator::current_source_lang() ); if ( '' === $title ) { $title = 'Accessibility'; } /* * The landmark name must always contain the literal English phrase * "Accessibility Options". Detection heuristics look for the two words * adjacent, so "Accessibility Hilfe Options" would NOT qualify, and a * localised title such as "Barrierefreiheit" would not either. Only * append when the exact phrase is missing, so an English install does * not get it twice. */ $label = $title; if ( false === stripos( $label, 'accessibility options' ) ) { // "Accessibility" / "Web Accessibility" -> just append " Options". // "Barrierefreiheit" / "Accessibility Hilfe" -> append the full phrase. $label .= preg_match( '/accessibility\s*$/i', $label ) ? ' Options' : ' – Accessibility Options'; } printf( '' . "\n", esc_attr( $position ), esc_attr( $label ), esc_attr( WELCOMA_WIDGET_VERSION ) ); } /** * Should the widget load on the current request? * * @return bool */ private function should_load() { $settings = Welcoma_Settings::get(); if ( empty( $settings['widget_enabled'] ) ) { return false; } if ( is_admin() || is_feed() ) { return false; } return (bool) apply_filters( 'welcoma_widget_load_widget', true ); } /** * Footer brand strings for the widget, or null when hidden. * * Always shown unless an add-on (Welcoma Pro, with a connected paid * licence) returns false from 'welcoma_widget_footer_brand_visible'. * * @return array|null */ private function footer_brand_data() { if ( ! apply_filters( 'welcoma_widget_footer_brand_visible', true ) ) { return null; } $texts = get_option( WELCOMA_WIDGET_FOOTER_BRAND_OPTION ); if ( ! is_array( $texts ) || empty( $texts ) ) { $texts = welcoma_widget_footer_brand_defaults(); } return array( 'texts' => $texts, 'url' => 'https://welcoma.app/', ); } /** * Enqueue the widget CSS/JS and hand it the active configuration. */ public function enqueue_assets() { if ( ! $this->should_load() ) { return; } $settings = Welcoma_Settings::get(); $active_modules = $this->active_modules( $settings ); // Pre-paint state restoration is shipped as a normal enqueued script. $this->enqueue_core_assets(); // ── Build title / branding translations (flat cache shape) ── $title_translations = array(); foreach ( Welcoma_Translator::all_lang_codes() as $lang ) { $val = Welcoma_Translator::get_string( 'accessibility_title', $lang ); if ( '' !== $val ) { $title_translations[ $lang ] = $val; } } // Ensure the current source-locale/settings value is always present // even before any background job has run. if ( empty( $title_translations['en'] ) ) { $title_translations['en'] = 'Accessibility'; } // Only the start language goes into the page. Every language would add ~150 KB to // each HTML page; other languages are fetched when a visitor switches (ajax_translations). $inline_langs = $this->initial_langs( $settings ); $translations = $this->build_translations( $active_modules, $inline_langs ); $statement_data = get_option( 'welcoma_statement', array( 'content' => '' ) ); $statement_data['translations'] = empty( $translations['statement'] ) ? null : $translations['statement']; $module_translations = $translations['module_translations']; $static_ui = $translations['static_ui']; $i18n_url = add_query_arg( array( 'action' => 'welcoma_i18n', 'v' => $this->translations_version( $active_modules ), ), admin_url( 'admin-ajax.php' ) ); $this->localize( $settings, $active_modules, $title_translations, $module_translations, $statement_data, $static_ui, $inline_langs, $i18n_url ); } /** * Modules shown in the widget: enabled ones plus every module a built-in profile uses. * * @param array $settings Widget settings. * @return array[] */ private function active_modules( $settings ) { // Add-ons register their own modules through welcoma_widget_modules. Core // does not perform payment/license checks for locally implemented features. $profile_preset_ids = array(); foreach ( Welcoma_Settings::builtin_profile_module_map() as $preset ) { if ( is_array( $preset ) ) { $profile_preset_ids = array_merge( $profile_preset_ids, array_keys( $preset ) ); } } $profile_preset_ids = array_unique( $profile_preset_ids ); $active_modules = array(); foreach ( welcoma_widget_get_modules() as $module ) { $id = $module['id']; if ( empty( $settings['enabled'][ $id ] ) && ! in_array( $id, $profile_preset_ids, true ) ) { continue; } $active_modules[] = array( 'id' => $id, 'name' => $module['name'], 'desc' => $module['desc'] ?? '', 'icon' => $module['icon'] ?? 'access', 'category' => $module['category'] ?? '', 'type' => $module['type'], 'stepped' => ! empty( $module['stepped'] ), 'is_custom' => ! empty( $module['is_custom'] ), 'modules' => ! empty( $module['is_custom'] ) && isset( $module['modules'] ) ? $module['modules'] : array(), ); } return $active_modules; } /** * CSS/JS of the widget. Minified files (built by the release workflow) unless * SCRIPT_DEBUG is on or they are missing (development checkout). */ private function enqueue_core_assets() { wp_enqueue_script( 'welcoma-prepaint', WELCOMA_WIDGET_URL . 'public/js/' . welcoma_widget_asset( 'welcoma-prepaint.js' ), array(), WELCOMA_WIDGET_VERSION, false // must run before the first paint to restore the visitor's settings ); wp_localize_script( 'welcoma-prepaint', 'welcomaPrepaintData', array( 'classMap' => Welcoma_Settings::prepaint_class_map() ) ); wp_enqueue_style( 'welcoma-widget', WELCOMA_WIDGET_URL . 'public/css/' . welcoma_widget_asset( 'welcoma-widget.css' ), array(), WELCOMA_WIDGET_VERSION ); wp_enqueue_style( 'welcoma-fonts', WELCOMA_WIDGET_URL . 'public/css/' . welcoma_widget_asset( 'welcoma-font.css' ), array(), WELCOMA_WIDGET_VERSION ); wp_enqueue_script( 'welcoma-widget', WELCOMA_WIDGET_URL . 'public/js/' . welcoma_widget_asset( 'welcoma-widget.js' ), array(), WELCOMA_WIDGET_VERSION, true // footer, not deferred: Pro extends window.WelcomaWidget before the widget's init on DOMContentLoaded ); } /** * Hand the widget its configuration. */ private function localize( $settings, $active_modules, $title_translations, $module_translations, $statement_data, $static_ui, $inline_langs, $i18n_url ) { wp_localize_script( 'welcoma-widget', 'welcomaWidgetData', array( 'modules' => $active_modules, 'footer_brand' => $this->footer_brand_data(), 'profile_module_map' => Welcoma_Settings::builtin_profile_module_map(), 'position' => $settings['position'], 'main_icon_bg' => $settings['main_icon_bg'], 'icon_color' => $settings['icon_color'], 'widget_icon' => $settings['widget_icon'], 'icon_size' => $settings['icon_size'], 'horizontal_offset' => $settings['horizontal_offset'], 'vertical_offset' => $settings['vertical_offset'], 'version' => $settings['version'], 'accessibility_title' => ! empty( $title_translations[ Welcoma_Translator::current_source_lang() ] ) ? $title_translations[ Welcoma_Translator::current_source_lang() ] : ( ! empty( $title_translations['en'] ) ? $title_translations['en'] : 'Accessibility' ), 'module_translations' => $module_translations, 'title_translations' => $title_translations, 'header_bg' => $settings['header_bg'], 'header_text_color' => $settings['header_text_color'], 'header_icon_bg' => $settings['header_icon_bg'], 'header_icon_color' => $settings['header_icon_color'], 'box_bg_color' => $settings['box_bg_color'], 'box_text_color' => $settings['box_text_color'], 'module_bg' => $settings['module_bg'], 'module_text_color' => $settings['module_text_color'], 'module_icon_color' => $settings['module_icon_color'], 'module_icon_bg_color' => $settings['module_icon_bg_color'], 'module_border_color' => $settings['module_border_color'], 'act_module_bg' => $settings['act_module_bg'], 'act_module_text_color' => $settings['act_module_text_color'], 'act_module_icon_color' => $settings['act_module_icon_color'], 'act_module_icon_bg_color' => $settings['act_module_icon_bg_color'], 'act_module_border_color' => $settings['act_module_border_color'], 'footer_bg' => $settings['footer_bg'], 'footer_text_color' => $settings['footer_text_color'], 'reset_btn_bg' => $settings['reset_btn_bg'], 'reset_btn_text_color' => $settings['reset_btn_text_color'], 'useWebsiteFont' => ! empty( $settings['use_website_font'] ), 'statement' => $statement_data, 'static_ui' => $static_ui, 'i18n' => array( 'title' => __( 'Accessibility', 'welcoma-accessibility-widget' ), 'open' => __( 'Open accessibility menu', 'welcoma-accessibility-widget' ), 'close' => __( 'Close', 'welcoma-accessibility-widget' ), 'reset' => __( 'Reset all', 'welcoma-accessibility-widget' ), 'statement' => __( 'Accessibility statement', 'welcoma-accessibility-widget' ), 'noModules' => __( 'No accessibility modules are enabled yet.', 'welcoma-accessibility-widget' ), ), 'default_language' => $settings['default_language'] ?? 'en', 'translation_source_lang' => Welcoma_Translator::current_source_lang(), 'enabled_languages' => ! empty( $settings['enabled_languages'] ) ? $settings['enabled_languages'] : array(), 'flags_url' => WELCOMA_WIDGET_URL . 'public/assets/flags/', 'i18n_loaded' => $inline_langs, 'i18n_url' => $i18n_url, ) ); } /** * Languages the visitor starts with: the admin default, or the first enabled language when * the default isn't enabled (same rule as the widget JS), plus the base language of a * regional code (pt-br → pt) because the widget falls back to it. * * @param array $settings Widget settings. * @return string[] */ private function initial_langs( $settings ) { $default = ! empty( $settings['default_language'] ) ? (string) $settings['default_language'] : 'en'; $enabled = ! empty( $settings['enabled_languages'] ) ? (array) $settings['enabled_languages'] : array(); if ( $enabled && ! in_array( $default, $enabled, true ) ) { foreach ( Welcoma_Translator::all_lang_codes() as $code ) { if ( in_array( $code, $enabled, true ) ) { $default = $code; break; } } } return $this->with_base_lang( array( $default ) ); } /** * @param string[] $langs Language codes. * @return string[] Codes plus their base language (pt-br → pt), only known codes. */ private function with_base_lang( array $langs ) { $known = Welcoma_Translator::all_lang_codes(); $out = array(); foreach ( $langs as $lang ) { foreach ( array( $lang, strtok( $lang, '-' ) ) as $code ) { if ( in_array( $code, $known, true ) && ! in_array( $code, $out, true ) ) { $out[] = $code; } } } return $out; } /** * Module, UI and statement translations for the given languages, in the shape the widget * reads (module_translations[id].name[lang], static_ui[key][lang], statement[lang]). * * @param array $active_modules Modules shown in the widget. * @param string[] $langs Language codes. * @return array{module_translations: array, static_ui: array, statement: array} */ private function build_translations( $active_modules, array $langs ) { $module_translations = array(); foreach ( $active_modules as $m ) { $id = $m['id']; $module_translations[ $id ] = array( 'name' => array(), 'desc' => array(), ); // Cache keys: 'mod__{id}__name' / '__desc' (custom profiles: 'custom_profile__…'). $prefix = ( empty( $m['is_custom'] ) ? 'mod__' : 'custom_profile__' ) . str_replace( '-', '_', $id ); foreach ( $langs as $lang ) { foreach ( array( 'name', 'desc' ) as $field ) { $val = Welcoma_Translator::get_string( $prefix . '__' . $field, $lang ); if ( '' !== $val ) { $module_translations[ $id ][ $field ][ $lang ] = $val; } } } } $statement = array(); foreach ( $langs as $lang ) { $val = Welcoma_Translator::get_string( 'statement', $lang ); if ( '' !== $val ) { // Inserted as HTML by the widget – filter on output, so translations cached // before the Pro translator sanitised them can't carry script either. $statement[ $lang ] = wp_kses_post( $val ); } } // Static UI strings follow the widget's own language switcher, not the site locale. $static_ui = array(); foreach ( array_keys( Welcoma_Translator::static_ui_strings() ) as $ui_key ) { foreach ( $langs as $lang ) { $val = Welcoma_Translator::get_string( $ui_key, $lang ); if ( '' !== $val ) { $static_ui[ $ui_key ][ $lang ] = $val; } } } return array( 'module_translations' => $module_translations, 'static_ui' => $static_ui, 'statement' => $statement, ); } /** * Changes whenever a translation, the module list or the statement changes, so browsers * can cache ajax_translations() responses for long and still never get stale text. * * @param array $active_modules Modules shown in the widget. * @return string */ private function translations_version( $active_modules ) { $statement = get_option( 'welcoma_statement', array() ); return substr( md5( WELCOMA_WIDGET_VERSION . '|' . serialize( Welcoma_Translator::get_cache() ) . '|' . wp_json_encode( wp_list_pluck( $active_modules, 'id' ) ) . '|' . wp_json_encode( $statement ) ), 0, 12 ); } /** * admin-ajax?action=welcoma_i18n&lang=xx – translations of one language for the widget's * language switcher. Public data, no nonce; cached by the browser (the URL carries a * content version). admin-ajax instead of REST: security plugins often block the REST API * for visitors. */ public function ajax_translations() { $lang = isset( $_GET['lang'] ) ? sanitize_text_field( wp_unslash( $_GET['lang'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification $settings = Welcoma_Settings::get(); if ( empty( $settings['widget_enabled'] ) || ! in_array( $lang, Welcoma_Translator::all_lang_codes(), true ) ) { wp_send_json_error( null, 404 ); } $langs = $this->with_base_lang( array( $lang ) ); $data = $this->build_translations( $this->active_modules( $settings ), $langs ); // admin-ajax sends no-cache headers, and wp_send_json() → wp_die() sends them again while // the output is still buffered – so a plain header() call gets overwritten. The filter // covers both calls. The URL carries a content version, so caching is safe. $cache_headers = static function () { return array( 'Cache-Control' => 'public, max-age=86400', 'Expires' => gmdate( 'D, d M Y H:i:s', time() + DAY_IN_SECONDS ) . ' GMT', ); }; add_filter( 'nocache_headers', $cache_headers, PHP_INT_MAX ); foreach ( $cache_headers() as $name => $value ) { header( $name . ': ' . $value ); } wp_send_json( array( 'langs' => $langs, 'module_translations' => $data['module_translations'], 'static_ui' => $data['static_ui'], 'statement' => $data['statement'], ) ); } }