PluginProbe
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) / 2.1.3
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) v2.1.3
2.1.3 2.1.2 2.1.1 2.1.0 2.0.4 2.0.3 2.0.2 2.0.1 2.0.0 1.5.5 1.5.4 1.5.3 1.5.2 1.5.1 1.5.0 trunk 1.0.1 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 All 57 releases
darkify / src / Frontend / Frontend.php

Frontend.php in Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) 2.1.3, at src/Frontend/Frontend.php

661 lines 31.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace ThemeAtelier\Darkify\Frontend;
4
5 use ThemeAtelier\Darkify\Includes\DarkifyExternalSupport;
6 use ThemeAtelier\Darkify\Includes\DarkifyUtils;
7
8 if (!defined('ABSPATH')) {
9 die;
10 } // Cannot access directly.
11
12 /**
13 * The public-facing functionality of the plugin.
14 *
15 * @link https://themeatelier.net
16 * @since 1.0.0
17 *
18 * @package Darkify
19 * @subpackage Darkify/public
20 */
21
22 /**
23 * The public-facing functionality of the plugin.
24 *
25 * Defines the plugin name, version, and two examples hooks for how to
26 * enqueue the public-facing stylesheet and JavaScript.
27 *
28 * @package Darkify
29 * @subpackage Darkify/public
30 * @author ThemeAtelier <themeatelierbd@gmail.com>
31 */
32 class Frontend
33 {
34
35 /**
36 * The ID of this plugin.
37 *
38 * @since 1.0.0
39 * @access private
40 * @var string $plugin_name The ID of this plugin.
41 */
42 private $plugin_name;
43
44 /**
45 * The version of this plugin.
46 *
47 * @since 1.0.0
48 * @access private
49 * @var string $version The current version of this plugin.
50 */
51 private $version;
52
53 public $utils;
54 public $settings;
55 public $external_support;
56 public $unique_id;
57
58 /**
59 * Initialize the class and set its properties.
60 *
61 * @since 1.0.0
62 * @param string $plugin_name The name of the plugin.
63 * @param string $version The version of this plugin.
64 */
65 public function __construct($plugin_name, $version)
66 {
67 $this->plugin_name = $plugin_name;
68 $this->version = $version;
69
70 $this->utils = new DarkifyUtils($this);
71 $this->external_support = new DarkifyExternalSupport($this);
72
73 new DarkifyShortcode($this);
74
75 if (function_exists('wp_rand')) {
76 $this->unique_id = wp_rand();
77 }
78
79 // Register unconditionally and gate inside the callback. The constructor
80 // runs at plugin boot (before `init`), so the option read here is always
81 // the SAVED value — deciding registration here means the admin Live
82 // Preview, which swaps in unsaved values only on `init` (see
83 // PreviewRest::maybe_enable_preview), could never show a newly enabled
84 // menu switch until saved. The filter runs at render time, after `init`,
85 // so re-reading the option in the callback sees the preview values.
86 add_filter('wp_nav_menu_items', array($this, 'darkify_switch_in_menu'), 10, 2);
87 // Block-theme and Kadence navigations never fire `wp_nav_menu_items` —
88 // they are blocks, not wp_nav_menu() calls — so they need their own hook.
89 add_filter('render_block', array($this, 'darkify_switch_in_block_menu'), 10, 2);
90
91 add_action('wp_enqueue_scripts', array($this, 'darkify_client_enqueue'), 100);
92 add_action('login_enqueue_scripts', array($this, 'darkify_client_enqueue'));
93 add_action('register_enqueue_scripts', array($this, 'darkify_client_enqueue'));
94 add_action('wp_head', array($this, 'darkify_client_header_script'), 1);
95 add_action('login_head', array($this, 'darkify_client_header_script'), 1);
96 add_action('register_head', array($this, 'darkify_client_header_script'), 1);
97 add_action('wp_footer', array($this, 'darkify_client_footer_script'));
98 add_action('login_footer', array($this, 'darkify_client_footer_script'));
99 add_action('register_footer', array($this, 'darkify_client_footer_script'));
100
101 add_action('init', array($this, 'darkify_register_switcher_styles'), 5);
102 add_filter('autoptimize_filter_js_exclude', array($this, 'darkify_exclude_js_from_cache_plugins'));
103 }
104
105 public function darkify_exclude_js_from_cache_plugins($excludes)
106 {
107 $excludes .= ',client_main.js,client_main.min.js';
108 return $excludes;
109 }
110
111 public function darkify_register_switcher_styles()
112 {
113 $min = (apply_filters('darkify_dev_mode', false) || WP_DEBUG) ? '' : '.min';
114 wp_register_style('darkify-client-main', DARKIFY_DIR_URL . 'src/assets/css/client_main' . $min . '.css', array(), DARKIFY_VERSION);
115 wp_register_style('theme-classic', DARKIFY_DIR_URL . 'src/assets/css/switcher/classic' . $min . '.css', array('darkify-client-main'), DARKIFY_VERSION, 'all');
116 wp_register_style('theme-expand', DARKIFY_DIR_URL . 'src/assets/css/switcher/expand' . $min . '.css', array('darkify-client-main'), DARKIFY_VERSION, 'all');
117 wp_register_style('theme-inner-moon', DARKIFY_DIR_URL . 'src/assets/css/switcher/inner-moon' . $min . '.css', array('darkify-client-main'), DARKIFY_VERSION, 'all');
118 wp_register_style('theme-within', DARKIFY_DIR_URL . 'src/assets/css/switcher/within' . $min . '.css', array('darkify-client-main'), DARKIFY_VERSION, 'all');
119 wp_register_style('theme-orbit', DARKIFY_DIR_URL . 'src/assets/css/switcher/orbit' . $min . '.css', array('darkify-client-main'), DARKIFY_VERSION, 'all');
120 }
121
122 function darkify_client_enqueue()
123 {
124 if ($this->darkify_is_dark_mode_allowed()) {
125 $options = get_option('darkify');
126 $min = (apply_filters('darkify_dev_mode', false) || WP_DEBUG) ? '' : '.min';
127
128 wp_enqueue_style('darkify-client-main', DARKIFY_DIR_URL . 'src/assets/css/client_main' . $min . '.css', array(), DARKIFY_VERSION);
129 wp_enqueue_script('darkify-client-main', DARKIFY_DIR_URL . 'src/assets/js/client_main' . $min . '.js', array(), DARKIFY_VERSION);
130
131 // When "Frontend Iframe Dark Mode" is OFF, use a two-stage inline script
132 // strategy that covers all scenarios — source JS, minified JS,
133 // cross-origin iframes, and same-origin WP pages embedded as iframes.
134 //
135 // ROOT CAUSE: a same-origin WP page loaded inside an <iframe> has its
136 // OWN copy of client_main[.min].js running. That copy calls
137 // darkify_check_preloading() → localStorage.darkify_last_state="1" →
138 // adds darkify_dark_mode_enabled to the iframe's <html> → goes dark.
139 // The parent's darkify_process_iframes guard is irrelevant here.
140 //
141 // Stage 1 — 'before' (runs BEFORE client_main[.min].js on EVERY page):
142 // If this page is rendering inside a frontend iframe, intercept the
143 // localStorage.darkify_last_state property via Object.defineProperty so
144 // that darkify_check_preloading() always reads "0" and returns false.
145 // This prevents the iframe page's own Darkify from initialising dark
146 // mode at all — works with both source and minified JS because
147 // darkify_check_preloading() always reads the property directly.
148 // The source JS also has a _dkf_iframe_disabled guard for belt-and-
149 // suspenders coverage.
150 //
151 // Stage 2 — 'after' (runs AFTER client_main[.min].js on EVERY page):
152 // • Cleans up the localStorage property intercept from Stage 1.
153 // • Overrides the five injection helpers so the PARENT page's
154 // darkify_apply_dark_to_iframe() applyDirect() closures (which look
155 // up these functions by name in the global scope at call time) become
156 // no-ops for cross-origin and not-yet-loaded iframes.
157 $enable_frontend_iframe = isset($options['enable_frontend_iframe_dark_mode']) ? $options['enable_frontend_iframe_dark_mode'] : true;
158 if (!$enable_frontend_iframe) {
159
160 // Stage 1 — intercept localStorage.darkify_last_state BEFORE client_main.
161 wp_add_inline_script(
162 'darkify-client-main',
163 '(function(){' .
164 // Only apply when this window is a child frame, the setting
165 // exists and is OFF, and it is not the admin panel context.
166 'if(window===window.top)return;' .
167 'if(typeof darkify_enable_frontend_iframe_dark_mode==="undefined"' .
168 '||darkify_enable_frontend_iframe_dark_mode==="1")return;' .
169 'if(typeof darkify_is_this_admin_panel!=="undefined"' .
170 '&&darkify_is_this_admin_panel==="1")return;' .
171 // Define an own property on localStorage that intercepts reads
172 // of darkify_last_state. darkify_check_preloading() uses
173 // localStorage.darkify_last_state (property access, not getItem),
174 // so this getter is hit every time the function runs.
175 // Returning "0" makes the function take the "was explicitly set
176 // to 0 → not preloaded" branch and return false.
177 // The setter is silently ignored so state writes in the iframe
178 // don't pollute the shared localStorage for other tabs/pages.
179 'try{' .
180 'Object.defineProperty(localStorage,"darkify_last_state",{' .
181 'get:function(){return"0";},set:function(){},configurable:true' .
182 '});' .
183 '}catch(e){' .
184 // Fallback for environments where Object.defineProperty on
185 // Storage objects is not supported: temporarily set the value.
186 'try{window._dkf_ls_bk=localStorage.darkify_last_state;' .
187 'localStorage.darkify_last_state="0";}catch(e2){}' .
188 '}' .
189 '})();',
190 'before'
191 );
192
193 // Stage 2 — cleanup + injection helper overrides AFTER client_main.
194 wp_add_inline_script(
195 'darkify-client-main',
196 '(function(){' .
197 // Remove the own-property intercept so subsequent reads of
198 // localStorage.darkify_last_state return the real stored value.
199 // "delete" removes the own property, restoring prototype lookup.
200 'if(window!==window.top){' .
201 'try{delete localStorage.darkify_last_state;}catch(e){' .
202 // Fallback: restore saved value
203 'try{if(typeof window._dkf_ls_bk!=="undefined"){' .
204 'if(window._dkf_ls_bk)localStorage.darkify_last_state=window._dkf_ls_bk;' .
205 'else localStorage.removeItem("darkify_last_state");' .
206 '}delete window._dkf_ls_bk;}catch(e2){}' .
207 '}' .
208 '}' .
209 // Override injection helpers so the PARENT page's applyDirect()
210 // closures (which already hold references to these global
211 // functions) become no-ops for frontend iframes.
212 'function _dkfAdmin(){' .
213 'return typeof darkify_is_this_admin_panel!=="undefined"&&darkify_is_this_admin_panel==="1";' .
214 '}' .
215 'if(typeof darkify_process_iframes==="function"){' .
216 'var _op=darkify_process_iframes;' .
217 'darkify_process_iframes=function(){if(_dkfAdmin())return _op();};' .
218 '}' .
219 'if(typeof darkify_inject_css_into_iframe==="function"){' .
220 'var _oi=darkify_inject_css_into_iframe;' .
221 'darkify_inject_css_into_iframe=function(d){if(_dkfAdmin())return _oi(d);};' .
222 '}' .
223 'if(typeof darkify_sync_iframe_vars==="function"){' .
224 'var _ov=darkify_sync_iframe_vars;' .
225 'darkify_sync_iframe_vars=function(d){if(_dkfAdmin())return _ov(d);};' .
226 '}' .
227 'if(typeof darkify_run_engine_on_iframe==="function"){' .
228 'var _oe=darkify_run_engine_on_iframe;' .
229 'darkify_run_engine_on_iframe=function(d){if(_dkfAdmin())return _oe(d);};' .
230 '}' .
231 'if(typeof darkify_inject_block_editor_css_into_iframe==="function"){' .
232 'var _ob=darkify_inject_block_editor_css_into_iframe;' .
233 'darkify_inject_block_editor_css_into_iframe=function(d){if(_dkfAdmin())return _ob(d);};' .
234 '}' .
235 '})();',
236 'after'
237 );
238 }
239
240 // Enqueue the global floating switcher style from settings
241 $enable_dark_mode_switch = isset($options['enable_dark_mode_switch']) ? $options['enable_dark_mode_switch'] : true;
242 if ($enable_dark_mode_switch) {
243 $global_variant = isset($options['enable_dark_switcher']) ? $options['enable_dark_switcher'] : 'classic';
244 wp_enqueue_style('theme-' . $global_variant);
245
246 // Separate mobile floating switcher (Different Switch in Mobile) — its toggler
247 // style may differ from the default, so enqueue that variant's CSS as well.
248 if (!empty($options['dark_switcher_switch_in_mobile'])) {
249 $mobile_variant = isset($options['enable_dark_switcher_in_mobile']) ? $options['enable_dark_switcher_in_mobile'] : 'classic';
250 wp_enqueue_style('theme-' . $this->darkify_switch_to_variant($mobile_variant));
251 }
252 }
253
254 // Detect [darkify] shortcode usage in current post and enqueue its variant
255 $post = get_post();
256 if ($post && !empty($post->post_content)) {
257 if (has_shortcode($post->post_content, 'darkify')) {
258 preg_match_all('/\[darkify\b[^\]]*\bswitch=["\']([^"\']+)["\'][^\]]*\]/i', $post->post_content, $matches);
259 $used_switches = !empty($matches[1]) ? $matches[1] : [];
260 foreach ($used_switches as $switch_val) {
261 wp_enqueue_style('theme-' . $this->darkify_switch_to_variant($switch_val));
262 }
263 // Shortcode without switch= attribute defaults to classic
264 preg_match_all('/\[darkify\b/i', $post->post_content, $all_sc);
265 if (count($all_sc[0]) > count($used_switches)) {
266 wp_enqueue_style('theme-classic');
267 }
268 }
269
270 // Detect darkify/switcher Gutenberg block usage and enqueue its variant
271 if (function_exists('has_block') && has_block('darkify/switcher', $post)) {
272 foreach ($this->darkify_get_block_variants($post->post_content) as $variant) {
273 wp_enqueue_style('theme-' . $variant);
274 }
275 }
276 }
277
278 // Detect switcher used in nav menus and enqueue its variant
279 $show_in_menu = isset($options['show_in_menu']) ? $options['show_in_menu'] : [];
280 $enable_in_menu = isset($show_in_menu['enable_switch_in_menu']) ? $show_in_menu['enable_switch_in_menu'] : false;
281 if ($enable_in_menu) {
282 $select_menus = isset($show_in_menu['select_menus']) ? $show_in_menu['select_menus'] : [];
283 foreach ($select_menus as $select_menu) {
284 $shortcode = $select_menu['darkify_menu_shortcode_helper'] ?? '';
285 if ($shortcode) {
286 preg_match('/\bswitch=["\']([^"\']+)["\']/', $shortcode, $m);
287 $switch_val = $m[1] ?? '1';
288 wp_enqueue_style('theme-' . $this->darkify_switch_to_variant($switch_val));
289 }
290 }
291 }
292 }
293 }
294
295 private function darkify_switch_to_variant($switch_value)
296 {
297 $map = [
298 '1' => 'classic', '2' => 'expand', '3' => 'inner-moon',
299 '4' => 'within', '5' => 'orbit',
300 ];
301 if (is_numeric($switch_value)) {
302 return $map[(string) $switch_value] ?? 'classic';
303 }
304 return $switch_value ?: 'classic';
305 }
306
307 private function darkify_get_block_variants($content)
308 {
309 $variants = [];
310 $this->darkify_collect_block_variants(parse_blocks($content), $variants);
311 return array_unique($variants);
312 }
313
314 private function darkify_collect_block_variants($blocks, &$variants)
315 {
316 foreach ($blocks as $block) {
317 if ('darkify/switcher' === $block['blockName']) {
318 $variants[] = $block['attrs']['style'] ?? 'classic';
319 }
320 if (!empty($block['innerBlocks'])) {
321 $this->darkify_collect_block_variants($block['innerBlocks'], $variants);
322 }
323 }
324
325 $options = get_option('darkify');
326 $min = (apply_filters('darkify_dev_mode', false) || WP_DEBUG) ? '' : '.min';
327
328 // Always register all variant styles so page builders (Elementor, etc.) can reference them.
329 wp_register_style('darkify-client-main', DARKIFY_DIR_URL . 'src/assets/css/client_main' . $min . '.css', array(), DARKIFY_VERSION);
330 wp_register_style('theme-classic', DARKIFY_DIR_URL . 'src/assets/css/switcher/classic' . $min . '.css', array('darkify-client-main'), DARKIFY_VERSION, 'all');
331 wp_register_style('theme-expand', DARKIFY_DIR_URL . 'src/assets/css/switcher/expand' . $min . '.css', array('darkify-client-main'), DARKIFY_VERSION, 'all');
332 wp_register_style('theme-inner-moon', DARKIFY_DIR_URL . 'src/assets/css/switcher/inner-moon' . $min . '.css', array('darkify-client-main'), DARKIFY_VERSION, 'all');
333 wp_register_style('theme-orbit', DARKIFY_DIR_URL . 'src/assets/css/switcher/orbit' . $min . '.css', array('darkify-client-main'), DARKIFY_VERSION, 'all');
334 wp_register_style('theme-within', DARKIFY_DIR_URL . 'src/assets/css/switcher/within' . $min . '.css', array('darkify-client-main'), DARKIFY_VERSION, 'all');
335
336 // Determine which variants are actually needed on this page.
337 $variants = $this->darkify_collect_page_variants($options);
338
339 if (empty($variants)) {
340 return;
341 }
342
343 // Enqueue main CSS + only the required variant CSS — all resolved before wp_head fires.
344 wp_enqueue_style('darkify-client-main');
345 foreach ($variants as $variant) {
346 wp_enqueue_style("theme-{$variant}");
347 }
348 wp_enqueue_script('darkify-client-main', DARKIFY_DIR_URL . 'src/assets/js/client_main' . $min . '.js', array(), DARKIFY_VERSION);
349 }
350
351 /**
352 * Collects all switcher variants needed on the current page so they can be
353 * enqueued during wp_enqueue_scripts (before wp_head), guaranteeing CSS loads
354 * in the <head> regardless of page builder or theme.
355 */
356 private function darkify_collect_page_variants($options)
357 {
358 $all_variants = array('classic', 'expand', 'inner-moon', 'orbit', 'within');
359 $num_map = array('1' => 'classic', '2' => 'expand', '3' => 'inner-moon', '4' => 'within', '5' => 'orbit');
360 $variants = array();
361
362 // 1. Global / floating switcher variant (always present when dark mode is active).
363 $global_variant = isset($options['enable_dark_switcher']) ? $options['enable_dark_switcher'] : 'classic';
364 if (in_array($global_variant, $all_variants, true)) {
365 $variants[] = $global_variant;
366 }
367
368 // 2. Menu switcher — parse variant from each saved shortcode helper string.
369 $show_in_menu = isset($options['show_in_menu']) ? $options['show_in_menu'] : array();
370 if (!empty($show_in_menu['enable_switch_in_menu']) && !empty($show_in_menu['select_menus'])) {
371 foreach ($show_in_menu['select_menus'] as $menu_item) {
372 $helper = isset($menu_item['darkify_menu_shortcode_helper']) ? $menu_item['darkify_menu_shortcode_helper'] : '';
373 if ($helper && preg_match('/switch=["\']?([^"\'>\s\]]+)["\']?/', $helper, $m)) {
374 $v = isset($num_map[$m[1]]) ? $num_map[$m[1]] : $m[1];
375 if (in_array($v, $all_variants, true)) {
376 $variants[] = $v;
377 }
378 }
379 }
380 }
381
382 // 3. Page-level detection — shortcodes, Gutenberg blocks, Elementor widget.
383 global $post;
384 if ($post) {
385 $content = $post->post_content;
386
387 // Shortcodes: parse switch attribute from every [darkify] tag.
388 if (has_shortcode($content, 'darkify')) {
389 preg_match_all('/\[darkify[^\]]*switch=["\']?([^"\'>\s\]]+)/', $content, $sc_matches);
390 foreach ($sc_matches[1] as $switch_val) {
391 $v = isset($num_map[$switch_val]) ? $num_map[$switch_val] : $switch_val;
392 if (in_array($v, $all_variants, true)) {
393 $variants[] = $v;
394 } else {
395 $variants[] = 'classic'; // default when no valid variant found
396 }
397 }
398 // If [darkify] exists but has no switch attribute, default to classic.
399 if (empty($sc_matches[1]) && has_shortcode($content, 'darkify')) {
400 $variants[] = 'classic';
401 }
402 }
403
404 // Gutenberg blocks: walk the block tree and collect style attributes.
405 if (function_exists('has_block') && has_block('darkify/switcher', $post)) {
406 $blocks = parse_blocks($content);
407 $variants = array_merge($variants, $this->darkify_extract_block_variants($blocks, $all_variants));
408 }
409
410 // Elementor widget: if Darkify widget appears in the page's Elementor data,
411 // load all variants — the specific style is stored as a control value that
412 // would require deep JSON parsing to extract reliably.
413 if (class_exists('\Elementor\Plugin')) {
414 $elementor_data = get_post_meta($post->ID, '_elementor_data', true);
415 if ($elementor_data && strpos($elementor_data, '"widgetType":"darkify"') !== false) {
416 return $all_variants;
417 }
418 }
419 }
420
421 return array_values(array_unique(array_filter($variants)));
422 }
423
424 /**
425 * Recursively walks a parsed block tree and collects darkify/darkify style values.
426 */
427 private function darkify_extract_block_variants($blocks, $all_variants)
428 {
429 $variants = array();
430 foreach ($blocks as $block) {
431 if ('darkify/switcher' === $block['blockName']) {
432 $style = isset($block['attrs']['style']) ? $block['attrs']['style'] : 'classic';
433 if (in_array($style, $all_variants, true)) {
434 $variants[] = $style;
435 }
436 }
437 if (!empty($block['innerBlocks'])) {
438 $variants = array_merge($variants, $this->darkify_extract_block_variants($block['innerBlocks'], $all_variants));
439 }
440 }
441 return $variants;
442 }
443 public function darkify_is_dark_mode_allowed()
444 {
445 $options = get_option('darkify');
446
447 // "Frontend Dark Mode" (enable_dark_mode_switch) is the MASTER control
448 // for the whole frontend feature. When it's off, load NOTHING on the
449 // front end — no CSS, no JS, no inline output — since every asset and
450 // inline script path is gated through this method. (Admin dark mode is a
451 // separate option handled elsewhere and is unaffected.)
452 $enable_dark_mode_switch = isset($options['enable_dark_mode_switch'])
453 ? $options['enable_dark_mode_switch']
454 : true;
455 if (! $enable_dark_mode_switch) {
456 return False;
457 }
458
459 /* Disable if Oxygen Builder is Opened */
460 if (isset($_GET['ct_builder'])) {
461 if ($_GET['ct_builder'] == "true") {
462 if (!isset($_GET['oxygen_iframe'])) {
463 return False;
464 }
465 }
466 }
467
468 return True;
469 }
470
471
472 public function darkify_client_header_script()
473 {
474 if ($this->darkify_is_dark_mode_allowed()) {
475 include_once DarkifyUtils::darkify_locate_template('header_script.php');
476 }
477 }
478
479 public function darkify_client_footer_script()
480 {
481 if ($this->darkify_is_dark_mode_allowed()) {
482 include_once DarkifyUtils::darkify_locate_template('footer_script.php');
483 }
484 }
485
486 function darkify_switch_in_menu($items, $args)
487 {
488 $options = get_option('darkify');
489 $show_in_menu = isset($options['show_in_menu']) ? $options['show_in_menu'] : '';
490
491 // Gate here (not at filter registration) so the live preview's unsaved
492 // "Enable Menu Switch" toggle takes effect without saving.
493 $enable_switcher_in_menu = isset($show_in_menu["enable_switch_in_menu"]) ? $show_in_menu["enable_switch_in_menu"] : false;
494 if (! $enable_switcher_in_menu) {
495 return $items;
496 }
497
498 $select_menus = isset($show_in_menu['select_menus']) ? $show_in_menu['select_menus'] : [];
499
500 $last_shortcode = '';
501
502 if ($this->darkify_is_dark_mode_allowed() && isset($args->menu->term_id)) {
503 foreach ($select_menus as $select_menu) {
504
505 $switch_in_menu_location = $select_menu['switch_in_menu_location'] ?? '';
506 $darkify_menu_shortcode_helper = $select_menu['darkify_menu_shortcode_helper'] ?? '';
507 $menu_position = isset($select_menu['select_menu_position']) ? $select_menu['select_menu_position'] : 'after';
508 if ($args->menu->term_id == $switch_in_menu_location) {
509 $last_shortcode = $darkify_menu_shortcode_helper;
510 }
511 }
512 if ($last_shortcode) {
513 $items = ($menu_position == 'before') ? '<li class="menu-item">' . do_shortcode($last_shortcode) . '</li>' . $items : $items . '<li class="menu-item">' . do_shortcode($last_shortcode) . '</li>';
514 }
515 }
516
517 return $items;
518 }
519
520 /**
521 * Put the switch inside a block-theme or Kadence navigation.
522 *
523 * `wp_nav_menu_items` only fires for classic wp_nav_menu() output, so on a
524 * block theme (and on any site using Kadence Navigation) the Menu Switch
525 * feature had nothing to hook: the menu rendered, the setting was saved, and
526 * no switch ever appeared. These navigations are blocks, so this is the
527 * equivalent hook for them.
528 *
529 * The selection is matched on the namespaced `<post_type>:<id>` key written
530 * by the picker (see AbstractRestController::darkify_block_menu_sources()),
531 * which is what keeps block and Kadence menu POST ids from being confused
532 * with classic menu TERM ids.
533 *
534 * @param string $block_content Rendered HTML for this block.
535 * @param array $block Parsed block, including its attributes.
536 * @return string
537 */
538 function darkify_switch_in_block_menu($block_content, $block)
539 {
540 // render_block fires for every block on the page, so reject as early and
541 // as cheaply as possible: two string comparisons for the overwhelming
542 // majority of blocks, and nothing else.
543 if (empty($block['blockName'])) {
544 return $block_content;
545 }
546
547 if ($block['blockName'] === 'core/navigation') {
548 $source = 'wp_navigation';
549 $ref = isset($block['attrs']['ref']) ? (int) $block['attrs']['ref'] : 0;
550 } elseif ($block['blockName'] === 'kadence/navigation') {
551 $source = 'kadence_navigation';
552 $ref = isset($block['attrs']['id']) ? (int) $block['attrs']['id'] : 0;
553 } else {
554 return $block_content;
555 }
556
557 // A navigation block with no saved menu behind it (unsaved, or rendering
558 // its fallback) has nothing this setting could have been pointed at.
559 if ($ref <= 0) {
560 return $block_content;
561 }
562
563 $options = get_option('darkify');
564 $show_in_menu = isset($options['show_in_menu']) ? $options['show_in_menu'] : '';
565
566 // Gated here rather than at filter registration for the same reason as
567 // the classic path: the live preview toggles this without saving.
568 if (empty($show_in_menu['enable_switch_in_menu'])) {
569 return $block_content;
570 }
571
572 if (! $this->darkify_is_dark_mode_allowed()) {
573 return $block_content;
574 }
575
576 $select_menus = isset($show_in_menu['select_menus']) ? $show_in_menu['select_menus'] : [];
577 $needle = $source . ':' . $ref;
578 $shortcode = '';
579 $position = 'after';
580
581 foreach ((array) $select_menus as $select_menu) {
582 if (($select_menu['switch_in_menu_location'] ?? '') !== $needle) {
583 continue;
584 }
585 // Last match wins, matching the classic path's behaviour. Unlike
586 // that path the position is read from the row that actually
587 // matched, so it cannot be taken from an unrelated row.
588 $shortcode = $select_menu['darkify_menu_shortcode_helper'] ?? '';
589 $position = $select_menu['select_menu_position'] ?? 'after';
590 }
591
592 if (! $shortcode) {
593 return $block_content;
594 }
595
596 $item = '<li class="menu-item">' . do_shortcode($shortcode) . '</li>';
597
598 $injected = $this->darkify_inject_menu_item($block_content, $item, $position);
599
600 // Unrecognised markup is left exactly as the block rendered it: a
601 // navigation with no switch is a far better outcome than a broken one.
602 return ($injected === null) ? $block_content : $injected;
603 }
604
605 /**
606 * A `<ul>` or `</ul>` tag, with capture 1 set to "/" on a closing tag.
607 *
608 * The alternation skips over quoted attribute values rather than using a
609 * plain `[^>]*`: an attribute may legitimately contain a `>` (`data-x="a>b"`),
610 * and a bare `[^>]*` ends the match in the middle of that value. The open
611 * tag would then appear to end mid-attribute, so a "before" insertion landed
612 * inside the attribute and broke the markup, and a `>` inside a submenu's
613 * own `<ul>` attributes miscounted the nesting depth below.
614 */
615 const DARKIFY_UL_TAG = '/<(\/?)ul\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>/i';
616
617 /**
618 * Insert a list item into the first top-level `<ul>` of a rendered block.
619 *
620 * The closing tag is found by balancing `<ul>`/`</ul>` rather than by
621 * searching for the last `</ul>`: navigations nest submenus, so the last one
622 * closes a submenu and the switch would land inside a dropdown.
623 *
624 * @return string|null Null when the markup has no usable list.
625 */
626 private function darkify_inject_menu_item($html, $item, $position)
627 {
628 if (! preg_match(self::DARKIFY_UL_TAG, $html, $open, PREG_OFFSET_CAPTURE)) {
629 return null;
630 }
631
632 // A fragment that opens on a closing tag has no list to add to.
633 if ($open[1][0] === '/') {
634 return null;
635 }
636
637 $after_open = $open[0][1] + strlen($open[0][0]);
638
639 if ($position === 'before') {
640 return substr($html, 0, $after_open) . $item . substr($html, $after_open);
641 }
642
643 $depth = 1;
644 $offset = $after_open;
645 $tag = null;
646
647 while ($depth > 0 && preg_match(self::DARKIFY_UL_TAG, $html, $tag, PREG_OFFSET_CAPTURE, $offset)) {
648 $depth += ($tag[1][0] === '/') ? -1 : 1;
649 $offset = $tag[0][1] + strlen($tag[0][0]);
650 }
651
652 if ($depth !== 0 || $tag === null) {
653 return null;
654 }
655
656 $before_close = $offset - strlen($tag[0][0]);
657
658 return substr($html, 0, $before_close) . $item . substr($html, $before_close);
659 }
660 }
661