PluginProbe
Blockenberg — 600+ Advanced Gutenberg Blocks for WordPress Block Editor / 2.0.12
Blockenberg — 600+ Advanced Gutenberg Blocks for WordPress Block Editor v2.0.12
2.0.12 2.0.11 2.0.10 2.0.9 trunk 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8
blockenberg / blockenberg.php

blockenberg.php in Blockenberg — 600+ Advanced Gutenberg Blocks for WordPress Block Editor 2.0.12, at blockenberg.php

1,288 lines 55.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Name: Blockenberg
4 * Description: Advanced Gutenberg Blocks for WordPress Block Editor
5 * Version: 2.0.12
6 * Author: Blockenberg
7 * Text Domain: blockenberg
8 * Domain Path: /languages
9 * License: GPLv2 or later
10 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
11 */
12
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * Ensure enough PHP memory to register 600+ blocks with layout attributes.
17 * The register_block_type_args filter adds ~400 extra attributes per block,
18 * which requires significantly more memory than the WordPress default 128M.
19 */
20 @ini_set( 'memory_limit', '512M' );
21
22 /**
23 * Google Fonts list for the Typography Control.
24 */
25 require_once __DIR__ . '/assets/php/google-fonts.php';
26
27 /**
28 * User Field block — dynamic PHP render for logged-in profile values.
29 */
30 require_once __DIR__ . '/blocks/user-field/render.php';
31
32 /**
33 * Admin dashboard — Block Manager (enable / disable blocks).
34 */
35 if ( is_admin() ) {
36 require_once __DIR__ . '/assets/php/admin-dashboard.php';
37 }
38
39 /**
40 * Enqueue common editor styles and scripts for all Blockenberg blocks
41 */
42 add_action( 'enqueue_block_editor_assets', function() {
43 wp_enqueue_style( 'dashicons' );
44 // Ensure Media Library modal assets are available for blocks using MediaUpload.
45 wp_enqueue_media();
46
47 // Common editor scripts (branded icons)
48 $editor_js = __DIR__ . '/assets/js/editor.js';
49 if ( file_exists( $editor_js ) ) {
50 wp_enqueue_script(
51 'bkbg-editor-common',
52 plugins_url( 'assets/js/editor.js', __FILE__ ),
53 array( 'wp-blocks', 'wp-dom-ready', 'wp-element' ),
54 filemtime( $editor_js ),
55 true
56 );
57 }
58
59 // Inspector tabs (General / Advanced) for all Blockenberg blocks
60 $inspector_tabs_js = __DIR__ . '/assets/js/inspector-tabs.js';
61 if ( file_exists( $inspector_tabs_js ) ) {
62 wp_enqueue_script(
63 'bkbg-inspector-tabs',
64 plugins_url( 'assets/js/inspector-tabs.js', __FILE__ ),
65 array( 'wp-blocks', 'wp-element', 'wp-compose', 'wp-hooks', 'wp-block-editor', 'wp-components', 'wp-i18n', 'wp-data' ),
66 filemtime( $inspector_tabs_js ),
67 true
68 );
69 }
70
71 // Typography Control — shared Elementor-like popover for all blocks
72 // Registered on init (so block scripts can safely depend on it).
73 wp_enqueue_script( 'bkbg-typography-control' );
74 });
75
76 /**
77 * Register shared editor scripts early (so other scripts can list them as deps).
78 */
79 add_action( 'init', function () {
80 $typo_js = __DIR__ . '/assets/js/typography-control.js';
81 if ( ! file_exists( $typo_js ) ) {
82 return;
83 }
84
85 wp_register_script(
86 'bkbg-typography-control',
87 plugins_url( 'assets/js/typography-control.js', __FILE__ ),
88 array( 'wp-element', 'wp-components', 'wp-i18n' ),
89 filemtime( $typo_js ),
90 true
91 );
92
93 // Pass Google Fonts list to JS as window.bkbgGoogleFonts
94 wp_localize_script(
95 'bkbg-typography-control',
96 'bkbgGoogleFonts',
97 function_exists( 'bkbg_google_fonts_list' ) ? bkbg_google_fonts_list() : array()
98 );
99
100 // Icon Picker — shared icon type selector + dashicon picker for all blocks
101 $icon_picker_js = __DIR__ . '/assets/js/icon-picker.js';
102 if ( file_exists( $icon_picker_js ) ) {
103 // Editor handle (needs WP component deps for UI)
104 wp_register_script(
105 'bkbg-icon-picker',
106 plugins_url( 'assets/js/icon-picker.js', __FILE__ ),
107 array( 'wp-element', 'wp-components', 'wp-i18n' ),
108 filemtime( $icon_picker_js ),
109 true
110 );
111 // Frontend handle (same file, no WP deps — only data + DOM builder)
112 wp_register_script(
113 'bkbg-icon-picker-frontend',
114 plugins_url( 'assets/js/icon-picker.js', __FILE__ ),
115 array(),
116 filemtime( $icon_picker_js ),
117 true
118 );
119 }
120 } );
121
122 /**
123 * Enqueue editor styles in a way compatible with the iframe-based editor canvas.
124 */
125 add_action( 'enqueue_block_assets', function () {
126 // Avoid loading editor-only CSS on the frontend.
127 if ( ! is_admin() ) {
128 return;
129 }
130
131 // Layout system CSS (shared variables and utilities)
132 $layout_css = __DIR__ . '/assets/css/layout.css';
133 if ( file_exists( $layout_css ) ) {
134 wp_enqueue_style(
135 'bkbg-layout-system',
136 plugins_url( 'assets/css/layout.css', __FILE__ ),
137 array(),
138 filemtime( $layout_css )
139 );
140 }
141
142 // Common editor styles for all blocks
143 $editor_css = __DIR__ . '/assets/css/editor.css';
144 if ( file_exists( $editor_css ) ) {
145 wp_enqueue_style(
146 'bkbg-editor-common',
147 plugins_url( 'assets/css/editor.css', __FILE__ ),
148 array( 'bkbg-layout-system' ),
149 filemtime( $editor_css )
150 );
151 }
152 } );
153
154 /**
155 * Register block scripts with dependencies (but don't enqueue them yet)
156 */
157 add_action( 'init', function () {
158 $blocks_dir = __DIR__ . '/blocks/';
159
160 // Standard WordPress script dependencies for blocks
161 $script_dependencies = array(
162 'wp-blocks',
163 'wp-element',
164 'wp-i18n',
165 'wp-block-editor',
166 'wp-components',
167 'wp-dom-ready',
168 'wp-data',
169 'bkbg-inspector-tabs',
170 'bkbg-typography-control',
171 'bkbg-icon-picker'
172 );
173
174 // Standard WordPress style dependencies for blocks
175 $style_dependencies = array(
176 'dashicons'
177 );
178
179 // Register layout system CSS for frontend
180 $layout_css = __DIR__ . '/assets/css/layout.css';
181 if ( file_exists( $layout_css ) ) {
182 wp_register_style(
183 'bkbg-layout-system',
184 plugins_url( 'assets/css/layout.css', __FILE__ ),
185 array(),
186 filemtime( $layout_css )
187 );
188 }
189
190 // Get disabled blocks list to skip asset registration.
191 $disabled_blocks = get_option( 'blockenberg_disabled_blocks', array() );
192 if ( ! is_array( $disabled_blocks ) ) {
193 $disabled_blocks = array();
194 }
195
196 // Automatically register scripts for all blocks
197 foreach ( glob( $blocks_dir . '*', GLOB_ONLYDIR ) as $block_dir ) {
198 $block_name = basename( $block_dir );
199
200 // Skip disabled blocks.
201 if ( in_array( 'blockenberg/' . $block_name, $disabled_blocks, true ) ) {
202 continue;
203 }
204
205 $script_file = $block_dir . '/index.js';
206
207 $style_file = $block_dir . '/style.css';
208 $frontend_file = $block_dir . '/frontend.js';
209
210 // Check if block has a JavaScript file
211 if ( file_exists( $script_file ) ) {
212 wp_register_script(
213 'bkbg-' . $block_name . '-editor',
214 plugins_url( 'blocks/' . $block_name . '/index.js', __FILE__ ),
215 $script_dependencies,
216 filemtime( $script_file ),
217 true
218 );
219 }
220
221 // Check if block has a CSS file
222 if ( file_exists( $style_file ) ) {
223 // Layout blocks need the layout system CSS
224 $block_style_deps = $style_dependencies;
225 if ( in_array( $block_name, array( 'section', 'row', 'column' ), true ) ) {
226 $block_style_deps[] = 'bkbg-layout-system';
227 }
228
229 wp_register_style(
230 'bkbg-' . $block_name . '-style',
231 plugins_url( 'blocks/' . $block_name . '/style.css', __FILE__ ),
232 $block_style_deps,
233 filemtime( $style_file )
234 );
235 }
236
237 // Check if block has a frontend JavaScript file
238 if ( file_exists( $frontend_file ) ) {
239 wp_register_script(
240 'bkbg-' . $block_name . '-frontend',
241 plugins_url( 'blocks/' . $block_name . '/frontend.js', __FILE__ ),
242 array( 'wp-dom-ready', 'bkbg-icon-picker-frontend' ),
243 filemtime( $frontend_file ),
244 true
245 );
246 }
247 }
248
249 // Automatically register all blocks in the /blocks directory
250 // Skip blocks the admin has disabled via the Blockenberg dashboard.
251 $disabled_blocks = get_option( 'blockenberg_disabled_blocks', array() );
252 if ( ! is_array( $disabled_blocks ) ) {
253 $disabled_blocks = array();
254 }
255
256 foreach ( glob( __DIR__ . '/blocks/*/block.json' ) as $metadata ) {
257 // Read block name from block.json to check against disabled list.
258 $raw_json = file_get_contents( $metadata );
259 $block_meta = $raw_json ? json_decode( $raw_json, true ) : null;
260 $block_name = is_array( $block_meta ) && isset( $block_meta['name'] ) ? $block_meta['name'] : '';
261
262 if ( '' !== $block_name && in_array( $block_name, $disabled_blocks, true ) ) {
263 continue; // Block is disabled — skip registration.
264 }
265
266 register_block_type( dirname( $metadata ) );
267 }
268 } );
269
270 // Register custom block category and ensure Blockenberg blocks appear in it.
271 add_filter( 'block_categories_all', function( $categories, $block_editor_context ) {
272 // Prepend Blockenberg sub-categories in reverse order so they appear in the right order.
273 $bkbg_categories = array(
274 array( 'slug' => 'blockenberg', 'title' => __( 'General (Blockenberg)', 'blockenberg' ), 'icon' => null ),
275 array( 'slug' => 'bkbg-layout', 'title' => __( 'Layout & Structure (Blockenberg)', 'blockenberg' ), 'icon' => null ),
276 array( 'slug' => 'bkbg-content', 'title' => __( 'Content & Typography (Blockenberg)', 'blockenberg' ), 'icon' => null ),
277 array( 'slug' => 'bkbg-media', 'title' => __( 'Media & Images (Blockenberg)', 'blockenberg' ), 'icon' => null ),
278 array( 'slug' => 'bkbg-marketing', 'title' => __( 'Marketing & Conversion (Blockenberg)','blockenberg' ), 'icon' => null ),
279 array( 'slug' => 'bkbg-business', 'title' => __( 'Business & Services (Blockenberg)', 'blockenberg' ), 'icon' => null ),
280 array( 'slug' => 'bkbg-blog', 'title' => __( 'Blog & Editorial (Blockenberg)', 'blockenberg' ), 'icon' => null ),
281 array( 'slug' => 'bkbg-interactive', 'title' => __( 'Interactive & Games (Blockenberg)', 'blockenberg' ), 'icon' => null ),
282 array( 'slug' => 'bkbg-charts', 'title' => __( 'Charts & Data (Blockenberg)', 'blockenberg' ), 'icon' => null ),
283 array( 'slug' => 'bkbg-calculators', 'title' => __( 'Calculators & Tools (Blockenberg)', 'blockenberg' ), 'icon' => null ),
284 array( 'slug' => 'bkbg-effects', 'title' => __( 'Effects & Animation (Blockenberg)', 'blockenberg' ), 'icon' => null ),
285 array( 'slug' => 'bkbg-dev', 'title' => __( 'Developer Tools (Blockenberg)', 'blockenberg' ), 'icon' => null ),
286 );
287 foreach ( array_reverse( $bkbg_categories ) as $cat ) {
288 array_unshift( $categories, $cat );
289 }
290 return $categories;
291 }, 10, 2 );
292
293 /**
294 * Load Google Fonts on the frontend for Blockenberg blocks.
295 * Scans block attributes for 'headerTypo', 'contentTypo', and any other
296 * attribute ending in 'Typo' that contains a non-empty 'family' key.
297 */
298 add_action( 'wp_enqueue_scripts', function () {
299 if ( ! is_singular() ) {
300 return;
301 }
302 $post = get_post();
303 if ( ! $post || ! has_blocks( $post->post_content ) ) {
304 return;
305 }
306
307 $system_fonts = array( 'Arial', 'Georgia', 'Helvetica', 'Tahoma', 'Times New Roman', 'Trebuchet MS', 'Verdana' );
308 $queued = array();
309
310 $blocks = parse_blocks( $post->post_content );
311
312 // Recursive walker for nested blocks
313 $collect = null;
314 $collect = function ( $blocks ) use ( &$collect, $system_fonts, &$queued ) {
315 foreach ( $blocks as $block ) {
316 if ( strpos( (string) $block['blockName'], 'blockenberg/' ) !== 0 ) {
317 if ( ! empty( $block['innerBlocks'] ) ) {
318 $collect( $block['innerBlocks'] );
319 }
320 continue;
321 }
322 $attrs = $block['attrs'] ?? array();
323 foreach ( $attrs as $key => $val ) {
324 // Any typography attribute (legacy *Typo suffix OR new typo* prefix)
325 // that is an array with a non-empty 'family' key.
326 $is_typo_key = ( substr( $key, -4 ) === 'Typo' ) || ( strpos( $key, 'typo' ) === 0 );
327 if ( $is_typo_key && is_array( $val ) && ! empty( $val['family'] ) ) {
328 $family = sanitize_text_field( $val['family'] );
329 if ( ! in_array( $family, $system_fonts, true ) && ! isset( $queued[ $family ] ) ) {
330 $queued[ $family ] = true;
331 $handle = 'bkbg-gf-' . sanitize_title( $family );
332 $url = 'https://fonts.googleapis.com/css2?family=' .
333 urlencode( $family ) .
334 ':wght@300;400;500;600;700;800;900&display=swap';
335 wp_enqueue_style( $handle, $url, array(), null );
336 }
337 }
338 }
339 if ( ! empty( $block['innerBlocks'] ) ) {
340 $collect( $block['innerBlocks'] );
341 }
342 }
343 };
344 $collect( $blocks );
345 } );
346
347 /**
348 * Register advanced layout attributes on the SERVER side for every Blockenberg block.
349 * Without this, WordPress strips unknown attributes during server-side parsing
350 * (array_intersect_key in WP_Block_Type::prepare_attributes_for_render).
351 */
352 add_filter( 'register_block_type_args', function ( $args, $block_type ) {
353 if ( strpos( $block_type, 'blockenberg/' ) !== 0 ) {
354 return $args;
355 }
356
357 // Blockenberg has its own Advanced spacing controls.
358 // Disable core Gutenberg "Dimensions" (spacing) UI to avoid duplicates.
359 if ( isset( $args['supports'] ) && is_array( $args['supports'] ) ) {
360 unset( $args['supports']['spacing'] );
361 unset( $args['supports']['__experimentalSpacing'] );
362 unset( $args['supports']['dimensions'] );
363 unset( $args['supports']['__experimentalDimensions'] );
364 }
365
366 $sides = array( 'Top', 'Right', 'Bottom', 'Left' );
367 $devices = array( '', 'Tablet', 'Mobile' );
368 $extra = array();
369
370 foreach ( array( 'bkbgMargin', 'bkbgPadding' ) as $prefix ) {
371 foreach ( $sides as $side ) {
372 foreach ( $devices as $device ) {
373 $key = $prefix . $side . $device;
374 $extra[ $key ] = array( 'type' => 'string', 'default' => '' );
375 $extra[ $key . 'Unit' ] = array( 'type' => 'string', 'default' => 'px' );
376 }
377 }
378 foreach ( $devices as $device ) {
379 $extra[ $prefix . 'Linked' . $device ] = array( 'type' => 'boolean', 'default' => true );
380 }
381 }
382
383 foreach ( $devices as $device ) {
384 $extra[ 'bkbgZIndex' . $device ] = array( 'type' => 'string', 'default' => '' );
385 }
386
387 $extra['bkbgCssId'] = array( 'type' => 'string', 'default' => '' );
388 $extra['bkbgCssClasses'] = array( 'type' => 'string', 'default' => '' );
389
390 // ── Background attributes ──
391 $extra['bkbgBgType'] = array( 'type' => 'string', 'default' => '' );
392 $extra['bkbgBgHoverType'] = array( 'type' => 'string', 'default' => '' );
393 $extra['bkbgBgColor'] = array( 'type' => 'string', 'default' => '' );
394 $extra['bkbgBgHoverColor']= array( 'type' => 'string', 'default' => '' );
395
396 // Classic image (responsive) — normal & hover
397 foreach ( array( 'bkbgBgImage', 'bkbgBgHoverImage' ) as $img_prefix ) {
398 foreach ( $devices as $device ) {
399 $extra[ $img_prefix . $device ] = array( 'type' => 'string', 'default' => '' );
400 $extra[ $img_prefix . 'Id' . $device ] = array( 'type' => 'number', 'default' => 0 );
401 }
402 }
403
404 // Classic image settings — normal & hover (position/repeat/size responsive, attachment global)
405 foreach ( array( 'bkbgBg', 'bkbgBgHover' ) as $s_prefix ) {
406 foreach ( $devices as $device ) {
407 $extra[ $s_prefix . 'Position' . $device ] = array( 'type' => 'string', 'default' => '' );
408 $extra[ $s_prefix . 'PositionCustomX' . $device ] = array( 'type' => 'string', 'default' => '' );
409 $extra[ $s_prefix . 'PositionCustomY' . $device ] = array( 'type' => 'string', 'default' => '' );
410 $extra[ $s_prefix . 'Repeat' . $device ] = array( 'type' => 'string', 'default' => '' );
411 $extra[ $s_prefix . 'Size' . $device ] = array( 'type' => 'string', 'default' => '' );
412 $extra[ $s_prefix . 'SizeCustomW' . $device ] = array( 'type' => 'string', 'default' => '' );
413 $extra[ $s_prefix . 'SizeCustomH' . $device ] = array( 'type' => 'string', 'default' => '' );
414 }
415 $extra[ $s_prefix . 'Attachment' ] = array( 'type' => 'string', 'default' => '' );
416 }
417
418 // Gradient — normal
419 $extra['bkbgBgGradColor1'] = array( 'type' => 'string', 'default' => '' );
420 $extra['bkbgBgGradColor2'] = array( 'type' => 'string', 'default' => '' );
421 $extra['bkbgBgGradType'] = array( 'type' => 'string', 'default' => 'linear' );
422 foreach ( $devices as $device ) {
423 $extra[ 'bkbgBgGradLoc1' . $device ] = array( 'type' => 'string', 'default' => '' );
424 $extra[ 'bkbgBgGradLoc2' . $device ] = array( 'type' => 'string', 'default' => '' );
425 $extra[ 'bkbgBgGradAngle' . $device ] = array( 'type' => 'string', 'default' => '' );
426 $extra[ 'bkbgBgGradPosition' . $device ] = array( 'type' => 'string', 'default' => '' );
427 }
428
429 // Gradient — hover
430 $extra['bkbgBgHoverGradColor1'] = array( 'type' => 'string', 'default' => '' );
431 $extra['bkbgBgHoverGradColor2'] = array( 'type' => 'string', 'default' => '' );
432 $extra['bkbgBgHoverGradType'] = array( 'type' => 'string', 'default' => 'linear' );
433 foreach ( $devices as $device ) {
434 $extra[ 'bkbgBgHoverGradLoc1' . $device ] = array( 'type' => 'string', 'default' => '' );
435 $extra[ 'bkbgBgHoverGradLoc2' . $device ] = array( 'type' => 'string', 'default' => '' );
436 $extra[ 'bkbgBgHoverGradAngle' . $device ] = array( 'type' => 'string', 'default' => '' );
437 $extra[ 'bkbgBgHoverGradPosition' . $device ] = array( 'type' => 'string', 'default' => '' );
438 }
439
440
441
442 // ── Border attributes ──
443
444 // Border Type (normal & hover)
445 $extra['bkbgBorderType'] = array( 'type' => 'string', 'default' => '' );
446 $extra['bkbgBorderHoverType'] = array( 'type' => 'string', 'default' => '' );
447
448 // Border Width — per side, per device, with unit (normal & hover)
449 foreach ( array( 'bkbgBorderWidth', 'bkbgBorderHoverWidth' ) as $prefix ) {
450 foreach ( $sides as $side ) {
451 foreach ( $devices as $device ) {
452 $key = $prefix . $side . $device;
453 $extra[ $key ] = array( 'type' => 'string', 'default' => '' );
454 $extra[ $key . 'Unit' ] = array( 'type' => 'string', 'default' => 'px' );
455 }
456 }
457 foreach ( $devices as $device ) {
458 $extra[ $prefix . 'Linked' . $device ] = array( 'type' => 'boolean', 'default' => true );
459 }
460 }
461
462 // Border Color (normal & hover)
463 $extra['bkbgBorderColor'] = array( 'type' => 'string', 'default' => '' );
464 $extra['bkbgBorderHoverColor'] = array( 'type' => 'string', 'default' => '' );
465
466 // Border Radius — per corner, per device, with unit (normal & hover)
467 foreach ( array( 'bkbgBorderRadius', 'bkbgBorderHoverRadius' ) as $prefix ) {
468 foreach ( $sides as $side ) {
469 foreach ( $devices as $device ) {
470 $key = $prefix . $side . $device;
471 $extra[ $key ] = array( 'type' => 'string', 'default' => '' );
472 $extra[ $key . 'Unit' ] = array( 'type' => 'string', 'default' => 'px' );
473 }
474 }
475 foreach ( $devices as $device ) {
476 $extra[ $prefix . 'Linked' . $device ] = array( 'type' => 'boolean', 'default' => true );
477 }
478 }
479
480 // Box Shadow (normal)
481 $extra['bkbgShadowColor'] = array( 'type' => 'string', 'default' => '' );
482 $extra['bkbgShadowH'] = array( 'type' => 'string', 'default' => '' );
483 $extra['bkbgShadowV'] = array( 'type' => 'string', 'default' => '' );
484 $extra['bkbgShadowBlur'] = array( 'type' => 'string', 'default' => '' );
485 $extra['bkbgShadowSpread'] = array( 'type' => 'string', 'default' => '' );
486 $extra['bkbgShadowPosition'] = array( 'type' => 'string', 'default' => '' );
487
488 // Box Shadow (hover)
489 $extra['bkbgShadowHoverColor'] = array( 'type' => 'string', 'default' => '' );
490 $extra['bkbgShadowHoverH'] = array( 'type' => 'string', 'default' => '' );
491 $extra['bkbgShadowHoverV'] = array( 'type' => 'string', 'default' => '' );
492 $extra['bkbgShadowHoverBlur'] = array( 'type' => 'string', 'default' => '' );
493 $extra['bkbgShadowHoverSpread'] = array( 'type' => 'string', 'default' => '' );
494 $extra['bkbgShadowHoverPosition'] = array( 'type' => 'string', 'default' => '' );
495
496
497 // Responsive visibility
498 $extra['bkbgHideDesktop'] = array( 'type' => 'boolean', 'default' => false );
499 $extra['bkbgHideTablet'] = array( 'type' => 'boolean', 'default' => false );
500 $extra['bkbgHideMobile'] = array( 'type' => 'boolean', 'default' => false );
501
502 if ( ! isset( $args['attributes'] ) || ! is_array( $args['attributes'] ) ) {
503 $args['attributes'] = array();
504 }
505 $args['attributes'] = array_merge( $args['attributes'], $extra );
506
507 return $args;
508 }, 10, 2 );
509
510 /**
511 * Render ALL advanced layout CSS (desktop + tablet + mobile) for Blockenberg blocks.
512 * Desktop styles are output as inline styles on the wrapper.
513 * Responsive styles use <style> tags with media queries.
514 * CSS ID / CSS Classes are also injected here for reliability.
515 */
516 add_filter( 'render_block', function ( $block_content, $block ) {
517 if ( empty( $block['blockName'] ) || strpos( $block['blockName'], 'blockenberg/' ) !== 0 ) {
518 return $block_content;
519 }
520
521 $attrs = $block['attrs'] ?? array();
522 if ( empty( $attrs ) ) {
523 return $block_content;
524 }
525
526 $sides = array( 'Top', 'Right', 'Bottom', 'Left' );
527 $devices = array( 'desktop', 'tablet', 'mobile' );
528
529 // Collect CSS rules per device
530 $css = array( 'desktop' => array(), 'tablet' => array(), 'mobile' => array() );
531
532 foreach ( array( 'bkbgMargin' => 'margin', 'bkbgPadding' => 'padding' ) as $prefix => $prop ) {
533 foreach ( $sides as $side ) {
534 foreach ( $devices as $dev ) {
535 $suffix = 'desktop' === $dev ? '' : ( 'tablet' === $dev ? 'Tablet' : 'Mobile' );
536 $val = isset( $attrs[ $prefix . $side . $suffix ] ) ? $attrs[ $prefix . $side . $suffix ] : '';
537 $unit = isset( $attrs[ $prefix . $side . $suffix . 'Unit' ] ) ? $attrs[ $prefix . $side . $suffix . 'Unit' ] : 'px';
538 if ( '' !== $val && '' !== trim( (string) $val ) ) {
539 $css[ $dev ][] = $prop . '-' . strtolower( $side ) . ':' . $val . $unit . ' !important';
540 }
541 }
542 }
543 }
544
545 // Z-Index per device
546 foreach ( $devices as $dev ) {
547 $suffix = 'desktop' === $dev ? '' : ( 'tablet' === $dev ? 'Tablet' : 'Mobile' );
548 $zi = isset( $attrs[ 'bkbgZIndex' . $suffix ] ) ? $attrs[ 'bkbgZIndex' . $suffix ] : '';
549 if ( '' !== $zi && '' !== trim( (string) $zi ) ) {
550 $css[ $dev ][] = 'z-index:' . intval( $zi );
551 $css[ $dev ][] = 'position:relative';
552 }
553 }
554
555 // ── Background CSS ──
556 $hover_css = array( 'desktop' => array(), 'tablet' => array(), 'mobile' => array() );
557
558 $bg_type = ! empty( $attrs['bkbgBgType'] ) ? $attrs['bkbgBgType'] : '';
559 $hover_type = ! empty( $attrs['bkbgBgHoverType'] ) ? $attrs['bkbgBgHoverType'] : '';
560
561 // Helper: build gradient value
562 $build_gradient = function ( $attrs, $prefix, $suffix ) {
563 $c1 = ! empty( $attrs[ $prefix . 'GradColor1' ] ) ? $attrs[ $prefix . 'GradColor1' ] : '';
564 $c2 = ! empty( $attrs[ $prefix . 'GradColor2' ] ) ? $attrs[ $prefix . 'GradColor2' ] : '';
565 if ( '' === $c1 && '' === $c2 ) return '';
566 if ( '' === $c1 ) $c1 = 'transparent';
567 if ( '' === $c2 ) $c2 = 'transparent';
568
569 // Location 1 — with fallback to desktop
570 $loc1 = '';
571 if ( '' !== $suffix && ! empty( $attrs[ $prefix . 'GradLoc1' . $suffix ] ) ) {
572 $loc1 = $attrs[ $prefix . 'GradLoc1' . $suffix ];
573 } elseif ( ! empty( $attrs[ $prefix . 'GradLoc1' ] ) ) {
574 $loc1 = $attrs[ $prefix . 'GradLoc1' ];
575 }
576
577 // Location 2
578 $loc2 = '';
579 if ( '' !== $suffix && ! empty( $attrs[ $prefix . 'GradLoc2' . $suffix ] ) ) {
580 $loc2 = $attrs[ $prefix . 'GradLoc2' . $suffix ];
581 } elseif ( ! empty( $attrs[ $prefix . 'GradLoc2' ] ) ) {
582 $loc2 = $attrs[ $prefix . 'GradLoc2' ];
583 }
584
585 // Angle
586 $angle = '';
587 if ( '' !== $suffix && isset( $attrs[ $prefix . 'GradAngle' . $suffix ] ) && '' !== $attrs[ $prefix . 'GradAngle' . $suffix ] ) {
588 $angle = $attrs[ $prefix . 'GradAngle' . $suffix ];
589 } elseif ( isset( $attrs[ $prefix . 'GradAngle' ] ) && '' !== $attrs[ $prefix . 'GradAngle' ] ) {
590 $angle = $attrs[ $prefix . 'GradAngle' ];
591 }
592
593 $type = ! empty( $attrs[ $prefix . 'GradType' ] ) ? $attrs[ $prefix . 'GradType' ] : 'linear';
594
595 $stop1 = esc_attr( $c1 ) . ( '' !== $loc1 ? ' ' . intval( $loc1 ) . '%' : '' );
596 $stop2 = esc_attr( $c2 ) . ( '' !== $loc2 ? ' ' . intval( $loc2 ) . '%' : '' );
597
598 if ( 'radial' === $type ) {
599 // Radial position
600 $pos = '';
601 if ( '' !== $suffix && ! empty( $attrs[ $prefix . 'GradPosition' . $suffix ] ) ) {
602 $pos = $attrs[ $prefix . 'GradPosition' . $suffix ];
603 } elseif ( ! empty( $attrs[ $prefix . 'GradPosition' ] ) ) {
604 $pos = $attrs[ $prefix . 'GradPosition' ];
605 }
606 $at_part = '' !== $pos ? ' at ' . esc_attr( $pos ) : '';
607 return 'radial-gradient(circle' . $at_part . ',' . $stop1 . ',' . $stop2 . ')';
608 }
609
610 $angle_part = '' !== $angle ? intval( $angle ) . 'deg,' : '';
611 return 'linear-gradient(' . $angle_part . $stop1 . ',' . $stop2 . ')';
612 };
613
614 // Helper: resolve responsive classic image settings (with desktop fallback)
615 $get_img_setting = function ( $attrs, $prefix, $prop, $suffix ) {
616 $val = ! empty( $attrs[ $prefix . $prop . $suffix ] ) ? $attrs[ $prefix . $prop . $suffix ] : '';
617 if ( '' === $val && '' !== $suffix ) {
618 $val = ! empty( $attrs[ $prefix . $prop ] ) ? $attrs[ $prefix . $prop ] : '';
619 }
620 return $val;
621 };
622
623 $render_classic_image_css = function ( $attrs, $prefix, &$target_css, $devices ) use ( $get_img_setting ) {
624 // Background color
625 if ( ! empty( $attrs[ $prefix . 'Color' ] ) ) {
626 $target_css['desktop'][] = 'background-color:' . esc_attr( $attrs[ $prefix . 'Color' ] ) . ' !important';
627 }
628 // Attachment (global, not responsive)
629 $attach = ! empty( $attrs[ $prefix . 'Attachment' ] ) ? esc_attr( $attrs[ $prefix . 'Attachment' ] ) : '';
630 if ( '' !== $attach ) {
631 $target_css['desktop'][] = 'background-attachment:' . $attach . ' !important';
632 }
633
634 foreach ( $devices as $dev ) {
635 $suffix = 'desktop' === $dev ? '' : ( 'tablet' === $dev ? 'Tablet' : 'Mobile' );
636 $img = ! empty( $attrs[ $prefix . 'Image' . $suffix ] ) ? $attrs[ $prefix . 'Image' . $suffix ] : '';
637 if ( '' === $img && '' !== $suffix ) {
638 $img = ! empty( $attrs[ $prefix . 'Image' ] ) ? $attrs[ $prefix . 'Image' ] : '';
639 }
640 if ( '' !== $img ) {
641 $target_css[ $dev ][] = 'background-image:url(' . esc_url( $img ) . ') !important';
642
643 // Position
644 $pos = $get_img_setting( $attrs, $prefix, 'Position', $suffix );
645 if ( 'custom' === $pos ) {
646 $cx = $get_img_setting( $attrs, $prefix, 'PositionCustomX', $suffix );
647 $cy = $get_img_setting( $attrs, $prefix, 'PositionCustomY', $suffix );
648 $cx = '' !== $cx ? intval( $cx ) . '%' : '50%';
649 $cy = '' !== $cy ? intval( $cy ) . '%' : '50%';
650 $target_css[ $dev ][] = 'background-position:' . $cx . ' ' . $cy . ' !important';
651 } elseif ( '' !== $pos ) {
652 $target_css[ $dev ][] = 'background-position:' . esc_attr( $pos ) . ' !important';
653 } else {
654 $target_css[ $dev ][] = 'background-position:center !important';
655 }
656
657 // Repeat
658 $repeat = $get_img_setting( $attrs, $prefix, 'Repeat', $suffix );
659 if ( '' !== $repeat ) {
660 $target_css[ $dev ][] = 'background-repeat:' . esc_attr( $repeat ) . ' !important';
661 }
662
663 // Size
664 $size = $get_img_setting( $attrs, $prefix, 'Size', $suffix );
665 if ( 'custom' === $size ) {
666 $sw = $get_img_setting( $attrs, $prefix, 'SizeCustomW', $suffix );
667 $sh = $get_img_setting( $attrs, $prefix, 'SizeCustomH', $suffix );
668 $sw = '' !== $sw ? intval( $sw ) . 'px' : 'auto';
669 $sh = '' !== $sh ? intval( $sh ) . 'px' : 'auto';
670 $target_css[ $dev ][] = 'background-size:' . $sw . ' ' . $sh . ' !important';
671 } elseif ( '' !== $size ) {
672 $target_css[ $dev ][] = 'background-size:' . esc_attr( $size ) . ' !important';
673 } else {
674 $target_css[ $dev ][] = 'background-size:cover !important';
675 }
676 }
677 }
678 };
679
680 // Normal — Classic
681 if ( 'classic' === $bg_type ) {
682 $render_classic_image_css( $attrs, 'bkbgBg', $css, $devices );
683 }
684
685 // Normal — Gradient
686 if ( 'gradient' === $bg_type ) {
687 foreach ( $devices as $dev ) {
688 $suffix = 'desktop' === $dev ? '' : ( 'tablet' === $dev ? 'Tablet' : 'Mobile' );
689 $grad = $build_gradient( $attrs, 'bkbgBg', $suffix );
690 if ( '' !== $grad ) {
691 $css[ $dev ][] = 'background-image:' . $grad . ' !important';
692 }
693 }
694 }
695
696 // Hover — Classic
697 if ( 'classic' === $hover_type ) {
698 $render_classic_image_css( $attrs, 'bkbgBgHover', $hover_css, $devices );
699 }
700
701 // Hover — Gradient
702 if ( 'gradient' === $hover_type ) {
703 foreach ( $devices as $dev ) {
704 $suffix = 'desktop' === $dev ? '' : ( 'tablet' === $dev ? 'Tablet' : 'Mobile' );
705 $grad = $build_gradient( $attrs, 'bkbgBgHover', $suffix );
706 if ( '' !== $grad ) {
707 $hover_css[ $dev ][] = 'background-image:' . $grad . ' !important';
708 }
709 }
710 }
711
712 // ── Border CSS ──
713
714 // Helper: build box-shadow value
715 $build_shadow = function ( $attrs, $prefix ) {
716 $h = isset( $attrs[ $prefix . 'H' ] ) && '' !== $attrs[ $prefix . 'H' ] ? intval( $attrs[ $prefix . 'H' ] ) : '';
717 $v = isset( $attrs[ $prefix . 'V' ] ) && '' !== $attrs[ $prefix . 'V' ] ? intval( $attrs[ $prefix . 'V' ] ) : '';
718 $blur = isset( $attrs[ $prefix . 'Blur' ] ) && '' !== $attrs[ $prefix . 'Blur' ] ? intval( $attrs[ $prefix . 'Blur' ] ) : '';
719 $spread = isset( $attrs[ $prefix . 'Spread' ] ) && '' !== $attrs[ $prefix . 'Spread' ] ? intval( $attrs[ $prefix . 'Spread' ] ) : '';
720 $color = ! empty( $attrs[ $prefix . 'Color' ] ) ? $attrs[ $prefix . 'Color' ] : '';
721 $pos = ! empty( $attrs[ $prefix . 'Position' ] ) ? $attrs[ $prefix . 'Position' ] : '';
722
723 if ( '' === $h && '' === $v && '' === $blur && '' === $spread && '' === $color ) return '';
724
725 $h = '' !== $h ? $h . 'px' : '0px';
726 $v = '' !== $v ? $v . 'px' : '0px';
727 $blur = '' !== $blur ? $blur . 'px' : '0px';
728 $spread = '' !== $spread ? $spread . 'px' : '0px';
729 $color = '' !== $color ? esc_attr( $color ) : 'rgba(0,0,0,0.5)';
730
731 $val = $h . ' ' . $v . ' ' . $blur . ' ' . $spread . ' ' . $color;
732 if ( 'inset' === $pos ) {
733 $val = 'inset ' . $val;
734 }
735 return $val;
736 };
737
738 // Helper: render border CSS for a prefix (normal or hover)
739 $render_border_css = function ( $attrs, $prefix_type, $prefix_width, $prefix_radius, $shadow_prefix, &$target_css, $devices ) use ( $build_shadow ) {
740 $border_type = ! empty( $attrs[ $prefix_type ] ) ? $attrs[ $prefix_type ] : '';
741
742 // Border type + width + color
743 if ( '' !== $border_type && 'none' !== $border_type ) {
744 $target_css['desktop'][] = 'border-style:' . esc_attr( $border_type ) . ' !important';
745
746 // Border color
747 $color_key = str_replace( 'Type', 'Color', $prefix_type );
748 if ( ! empty( $attrs[ $color_key ] ) ) {
749 $target_css['desktop'][] = 'border-color:' . esc_attr( $attrs[ $color_key ] ) . ' !important';
750 }
751
752 // Border width (responsive, per side)
753 foreach ( $devices as $dev ) {
754 $suffix = 'desktop' === $dev ? '' : ( 'tablet' === $dev ? 'Tablet' : 'Mobile' );
755 foreach ( array( 'Top', 'Right', 'Bottom', 'Left' ) as $side ) {
756 $val = isset( $attrs[ $prefix_width . $side . $suffix ] ) ? $attrs[ $prefix_width . $side . $suffix ] : '';
757 $unit = isset( $attrs[ $prefix_width . $side . $suffix . 'Unit' ] ) ? $attrs[ $prefix_width . $side . $suffix . 'Unit' ] : 'px';
758 if ( '' !== $val && '' !== trim( (string) $val ) ) {
759 $target_css[ $dev ][] = 'border-' . strtolower( $side ) . '-width:' . $val . $unit . ' !important';
760 }
761 }
762 }
763 } elseif ( 'none' === $border_type ) {
764 $target_css['desktop'][] = 'border:none !important';
765 }
766
767 // Border radius (responsive, per corner)
768 foreach ( $devices as $dev ) {
769 $suffix = 'desktop' === $dev ? '' : ( 'tablet' === $dev ? 'Tablet' : 'Mobile' );
770 $radius_parts = array();
771 foreach ( array( 'Top', 'Right', 'Bottom', 'Left' ) as $side ) {
772 $val = isset( $attrs[ $prefix_radius . $side . $suffix ] ) ? $attrs[ $prefix_radius . $side . $suffix ] : '';
773 $unit = isset( $attrs[ $prefix_radius . $side . $suffix . 'Unit' ] ) ? $attrs[ $prefix_radius . $side . $suffix . 'Unit' ] : 'px';
774 if ( '' !== $val && '' !== trim( (string) $val ) ) {
775 $radius_parts[ $side ] = $val . $unit;
776 }
777 }
778 if ( ! empty( $radius_parts ) ) {
779 // Map Top/Right/Bottom/Left to border-radius corners: TL TR BR BL
780 $tl = isset( $radius_parts['Top'] ) ? $radius_parts['Top'] : '0px';
781 $tr = isset( $radius_parts['Right'] ) ? $radius_parts['Right'] : '0px';
782 $br = isset( $radius_parts['Bottom'] ) ? $radius_parts['Bottom'] : '0px';
783 $bl = isset( $radius_parts['Left'] ) ? $radius_parts['Left'] : '0px';
784 $target_css[ $dev ][] = 'border-radius:' . $tl . ' ' . $tr . ' ' . $br . ' ' . $bl . ' !important';
785 }
786 }
787
788 // Box shadow
789 $shadow_val = $build_shadow( $attrs, $shadow_prefix );
790 if ( '' !== $shadow_val ) {
791 $target_css['desktop'][] = 'box-shadow:' . $shadow_val . ' !important';
792 }
793 };
794
795 // Normal border
796 $render_border_css( $attrs, 'bkbgBorderType', 'bkbgBorderWidth', 'bkbgBorderRadius', 'bkbgShadow', $css, $devices );
797
798 // Hover border
799 $render_border_css( $attrs, 'bkbgBorderHoverType', 'bkbgBorderHoverWidth', 'bkbgBorderHoverRadius', 'bkbgShadowHover', $hover_css, $devices );
800
801 // ── Responsive visibility ──
802 $hide_desktop = ! empty( $attrs['bkbgHideDesktop'] );
803 $hide_tablet = ! empty( $attrs['bkbgHideTablet'] );
804 $hide_mobile = ! empty( $attrs['bkbgHideMobile'] );
805
806 // Check if hover styles exist
807 $has_hover = ! empty( $hover_css['desktop'] ) || ! empty( $hover_css['tablet'] ) || ! empty( $hover_css['mobile'] );
808
809 // Any styles to output?
810 $has_responsive = $hide_desktop || $hide_tablet || $hide_mobile;
811 $has_styles = ! empty( $css['desktop'] ) || ! empty( $css['tablet'] ) || ! empty( $css['mobile'] ) || $has_hover || $has_responsive;
812 $has_id = ! empty( $attrs['bkbgCssId'] );
813 $has_cls = ! empty( $attrs['bkbgCssClasses'] );
814
815 if ( ! $has_styles && ! $has_id && ! $has_cls ) {
816 return $block_content;
817 }
818
819 // Generate a unique class for targeting this specific block instance
820 $unique = 'bkbg-adv-' . substr( md5( serialize( $attrs ) . wp_rand() ), 0, 8 );
821
822 // Inject unique class into the first HTML tag
823 $block_content = preg_replace(
824 '/(^\s*<[a-zA-Z][^>]*\bclass\s*=\s*")/',
825 '$1' . esc_attr( $unique ) . ' ',
826 $block_content,
827 1,
828 $count
829 );
830 if ( ! $count ) {
831 // No class attribute found — add one
832 $block_content = preg_replace(
833 '/(^\s*<[a-zA-Z][^\s>]*)/',
834 '$1 class="' . esc_attr( $unique ) . '"',
835 $block_content,
836 1
837 );
838 }
839
840 // Inject CSS ID
841 if ( $has_id ) {
842 $safe_id = esc_attr( $attrs['bkbgCssId'] );
843 $block_content = preg_replace(
844 '/(^\s*<[a-zA-Z][^>]*)/',
845 '$1 id="' . $safe_id . '"',
846 $block_content,
847 1
848 );
849 }
850
851 // Inject CSS Classes
852 if ( $has_cls ) {
853 $safe_cls = esc_attr( $attrs['bkbgCssClasses'] );
854 $block_content = preg_replace(
855 '/(^\s*<[a-zA-Z][^>]*\bclass\s*=\s*")/',
856 '$1' . $safe_cls . ' ',
857 $block_content,
858 1
859 );
860 }
861
862 // Build <style> tag
863 if ( $has_styles ) {
864 $sel = '.' . $unique;
865 $style = '';
866
867 if ( ! empty( $css['desktop'] ) ) {
868 $style .= $sel . '{' . implode( ';', $css['desktop'] ) . '}';
869 }
870 if ( ! empty( $css['tablet'] ) ) {
871 $style .= '@media(max-width:1024px){' . $sel . '{' . implode( ';', $css['tablet'] ) . '}}';
872 }
873 if ( ! empty( $css['mobile'] ) ) {
874 $style .= '@media(max-width:767px){' . $sel . '{' . implode( ';', $css['mobile'] ) . '}}';
875 }
876
877 // Hover rules
878 if ( ! empty( $hover_css['desktop'] ) ) {
879 $style .= $sel . ':hover{' . implode( ';', $hover_css['desktop'] ) . '}';
880 }
881 if ( ! empty( $hover_css['tablet'] ) ) {
882 $style .= '@media(max-width:1024px){' . $sel . ':hover{' . implode( ';', $hover_css['tablet'] ) . '}}';
883 }
884 if ( ! empty( $hover_css['mobile'] ) ) {
885 $style .= '@media(max-width:767px){' . $sel . ':hover{' . implode( ';', $hover_css['mobile'] ) . '}}';
886 }
887
888 // Responsive visibility: hide on specific devices
889 if ( $hide_desktop ) {
890 // Hide on desktop (>1024px)
891 $style .= '@media(min-width:1025px){' . $sel . '{display:none !important}}';
892 }
893 if ( $hide_tablet ) {
894 // Hide on tablet (768–1024px)
895 $style .= '@media(min-width:768px) and (max-width:1024px){' . $sel . '{display:none !important}}';
896 }
897 if ( $hide_mobile ) {
898 // Hide on mobile (≤767px)
899 $style .= '@media(max-width:767px){' . $sel . '{display:none !important}}';
900 }
901
902 $block_content .= '<style>' . $style . '</style>';
903 }
904
905 return $block_content;
906 }, 10, 2 );
907
908 /**
909 * Register REST API endpoint for Post Grid block.
910 *
911 * Route: /wp-json/blockenberg/v1/post-grid
912 * Method: GET
913 * Params:
914 * - type: string (posts|pages|any CPT)
915 * - orderby: string (date|title|comment_count|...)
916 * - order: string (asc|desc)
917 * - per_page: int
918 * - offset: int
919 * - page: int (1-based)
920 * - excerpt_len: int (optional)
921 */
922 add_action( 'rest_api_init', function () {
923 register_rest_route( 'blockenberg/v1', '/post-grid', array(
924 'methods' => 'GET',
925 'permission_callback' => '__return_true',
926 'args' => array(
927 'type' => array( 'sanitize_callback' => 'sanitize_key' ),
928 'orderby' => array( 'sanitize_callback' => 'sanitize_key' ),
929 'order' => array( 'sanitize_callback' => 'sanitize_text_field' ),
930 'per_page' => array( 'sanitize_callback' => 'absint' ),
931 'offset' => array( 'sanitize_callback' => 'absint' ),
932 'page' => array( 'sanitize_callback' => 'absint' ),
933 'excerpt_len' => array( 'sanitize_callback' => 'absint' ),
934 ),
935 'callback' => function ( WP_REST_Request $request ) {
936 $requested_type = sanitize_key( $request->get_param( 'type' ) ?: 'post' );
937 if ( 'posts' === $requested_type ) {
938 $requested_type = 'post';
939 } elseif ( 'pages' === $requested_type ) {
940 $requested_type = 'page';
941 }
942
943 $public_post_types = get_post_types( array( 'public' => true ), 'names' );
944 $post_type = in_array( $requested_type, $public_post_types, true ) ? $requested_type : 'post';
945
946 $requested_orderby = sanitize_key( $request->get_param( 'orderby' ) ?: 'date' );
947 $allowed_orderby = array( 'date', 'title', 'modified', 'comment_count', 'rand', 'menu_order' );
948 $orderby = in_array( $requested_orderby, $allowed_orderby, true ) ? $requested_orderby : 'date';
949 $order = strtolower( (string) $request->get_param( 'order' ) ) === 'asc' ? 'ASC' : 'DESC';
950 $per_page = max( 1, absint( $request->get_param( 'per_page' ) ?: 6 ) );
951 $per_page = min( 50, $per_page );
952 $offset = max( 0, absint( $request->get_param( 'offset' ) ?: 0 ) );
953 $offset = min( 5000, $offset );
954 $page = max( 1, absint( $request->get_param( 'page' ) ?: 1 ) );
955 $page = min( 200, $page );
956 $excerpt_len = max( 5, absint( $request->get_param( 'excerpt_len' ) ?: 18 ) );
957 $excerpt_len = min( 80, $excerpt_len );
958
959 // Short cache for public non-product queries.
960 $is_product_query = ( 'product' === $post_type );
961 $cache_key = '';
962 if ( ! $is_product_query ) {
963 $cache_key = 'bkbg_post_grid_' . md5( wp_json_encode( array(
964 'type' => $post_type,
965 'orderby' => $orderby,
966 'order' => $order,
967 'per_page' => $per_page,
968 'offset' => $offset,
969 'page' => $page,
970 'excerpt_len' => $excerpt_len,
971 ) ) );
972 $cached = get_transient( $cache_key );
973 if ( is_array( $cached ) ) {
974 return rest_ensure_response( $cached );
975 }
976 }
977
978 $q = new WP_Query( array(
979 'post_type' => $post_type,
980 'post_status' => 'publish',
981 'orderby' => $orderby,
982 'order' => $order,
983 'posts_per_page' => $per_page,
984 'offset' => $offset + ( ( $page - 1 ) * $per_page ),
985 'ignore_sticky_posts' => true,
986 'no_found_rows' => true,
987 ) );
988
989 $posts = array();
990 foreach ( $q->posts as $p ) {
991 $post_id = $p->ID;
992 $title = wp_strip_all_tags( get_the_title( $post_id ) );
993 $link = esc_url_raw( get_permalink( $post_id ) );
994 $image = esc_url_raw( (string) get_the_post_thumbnail_url( $post_id, 'medium_large' ) );
995 $date = wp_strip_all_tags( (string) get_the_date( '', $post_id ) );
996 $author = sanitize_text_field( (string) get_the_author_meta( 'display_name', $p->post_author ) );
997 $meta = trim( $date . ' · ' . $author );
998 $raw = get_post_field( 'post_excerpt', $post_id );
999 if ( '' === $raw ) {
1000 $raw = get_post_field( 'post_content', $post_id );
1001 }
1002 $excerpt = wp_strip_all_tags( wp_trim_words( wp_strip_all_tags( $raw ), $excerpt_len, '' ) );
1003
1004 $item = array(
1005 'id' => $post_id,
1006 'title' => $title,
1007 'link' => $link,
1008 'image' => $image,
1009 'meta' => $meta,
1010 'excerpt' => $excerpt,
1011 );
1012
1013 if ( 'product' === $post_type && function_exists( 'wc_get_product' ) ) {
1014 $product = wc_get_product( $post_id );
1015 if ( $product ) {
1016 $item['price_html'] = wp_kses_post( $product->get_price_html() );
1017 $item['add_to_cart'] = esc_url_raw( $product->add_to_cart_url() );
1018 }
1019 }
1020
1021 $posts[] = $item;
1022 }
1023
1024 $payload = array( 'posts' => $posts );
1025
1026 if ( ! $is_product_query && '' !== $cache_key ) {
1027 set_transient( $cache_key, $payload, 60 );
1028 }
1029
1030 return rest_ensure_response( $payload );
1031 },
1032 ) );
1033 } );
1034
1035 /**
1036 * Simple local newsletter subscribe endpoint.
1037 * Stores emails in an option array; extend as needed (e.g., to a custom table).
1038 */
1039 add_action( 'rest_api_init', function () {
1040 register_rest_route( 'blockenberg/v1', '/subscribe', array(
1041 'methods' => 'POST',
1042 'permission_callback' => '__return_true',
1043 'args' => array(
1044 'email' => array( 'sanitize_callback' => 'sanitize_email' ),
1045 'website' => array( 'sanitize_callback' => 'sanitize_text_field' ),
1046 ),
1047 'callback' => function ( WP_REST_Request $request ) {
1048 $payload = $request->get_json_params();
1049 if ( ! is_array( $payload ) ) {
1050 $payload = array();
1051 }
1052
1053 // Basic rate limiting by IP to reduce spam / abuse.
1054 $ip = '';
1055 if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
1056 $ip = sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) );
1057 }
1058 if ( '' !== $ip ) {
1059 $key = 'bkbg_subscribe_' . md5( $ip );
1060 $rl = get_transient( $key );
1061 if ( is_array( $rl ) && isset( $rl['count'], $rl['start'] ) ) {
1062 if ( $rl['count'] >= 5 ) {
1063 return new WP_Error( 'rate_limited', __( 'Too many requests. Please try again later.', 'blockenberg' ), array( 'status' => 429 ) );
1064 }
1065 $rl['count']++;
1066 set_transient( $key, $rl, 60 );
1067 } else {
1068 set_transient( $key, array( 'count' => 1, 'start' => time() ), 60 );
1069 }
1070 }
1071
1072 // Optional honeypot field (bots tend to fill it).
1073 $honeypot = isset( $payload['website'] ) ? sanitize_text_field( (string) $payload['website'] ) : '';
1074 if ( '' !== $honeypot ) {
1075 return rest_ensure_response( array( 'ok' => true ) );
1076 }
1077
1078 $email = isset( $payload['email'] ) ? sanitize_email( (string) $payload['email'] ) : '';
1079 if ( empty( $email ) || ! is_email( $email ) ) {
1080 return new WP_Error( 'invalid_email', __( 'Invalid email address', 'blockenberg' ), array( 'status' => 400 ) );
1081 }
1082
1083 $list = get_option( 'blockenberg_newsletter_subscribers', array() );
1084 if ( ! is_array( $list ) ) {
1085 $list = array();
1086 }
1087
1088 // Prevent unbounded option growth.
1089 if ( count( $list ) >= 5000 ) {
1090 return new WP_Error( 'storage_full', __( 'Subscriber list is full.', 'blockenberg' ), array( 'status' => 503 ) );
1091 }
1092
1093 if ( ! in_array( $email, $list, true ) ) {
1094 $list[] = $email;
1095 update_option( 'blockenberg_newsletter_subscribers', $list, false );
1096 }
1097 return rest_ensure_response( array( 'ok' => true ) );
1098 },
1099 ) );
1100 } );
1101
1102 /**
1103 * Strip CR/LF and related sequences so values cannot inject mail headers.
1104 *
1105 * @param string $value Raw header fragment.
1106 * @return string
1107 */
1108 function bkbg_sanitize_mail_header( $value ) {
1109 $value = (string) $value;
1110 $value = str_replace( array( "\r", "\n", '%0a', '%0d', '%0A', '%0D' ), '', $value );
1111 return trim( sanitize_text_field( $value ) );
1112 }
1113
1114 /**
1115 * HMAC signature for a contact-form recipient email.
1116 * Prevents open-relay abuse: the client may only use a recipient that was
1117 * signed server-side when the block was rendered.
1118 *
1119 * @param string $email Recipient email.
1120 * @return string Hex HMAC or empty string if invalid.
1121 */
1122 function bkbg_contact_recipient_sig( $email ) {
1123 $email = strtolower( sanitize_email( (string) $email ) );
1124 if ( ! is_email( $email ) ) {
1125 return '';
1126 }
1127 return hash_hmac( 'sha256', $email, wp_salt( 'auth' ) );
1128 }
1129
1130 /**
1131 * Verify a contact-form recipient signature.
1132 *
1133 * @param string $email Recipient email from the client.
1134 * @param string $sig HMAC from data-recipient-sig.
1135 * @return bool
1136 */
1137 function bkbg_verify_contact_recipient( $email, $sig ) {
1138 $email = strtolower( sanitize_email( (string) $email ) );
1139 if ( ! is_email( $email ) || ! is_string( $sig ) || '' === $sig ) {
1140 return false;
1141 }
1142 $expected = bkbg_contact_recipient_sig( $email );
1143 return ( '' !== $expected && hash_equals( $expected, $sig ) );
1144 }
1145
1146 /**
1147 * Inject a server-signed recipient HMAC into Contact Form markup on render.
1148 * Existing posts do not need to be re-saved — the signature is added at runtime.
1149 */
1150 add_filter( 'render_block', function ( $block_content, $block ) {
1151 if ( empty( $block['blockName'] ) || 'blockenberg/contact-form' !== $block['blockName'] ) {
1152 return $block_content;
1153 }
1154 if ( ! is_string( $block_content ) || '' === $block_content ) {
1155 return $block_content;
1156 }
1157
1158 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
1159 $recipient = isset( $attrs['recipientEmail'] ) ? sanitize_email( (string) $attrs['recipientEmail'] ) : '';
1160 if ( ! is_email( $recipient ) ) {
1161 return $block_content;
1162 }
1163
1164 $sig = bkbg_contact_recipient_sig( $recipient );
1165 if ( '' === $sig ) {
1166 return $block_content;
1167 }
1168
1169 // Replace existing sig if present, otherwise inject onto the first opening tag.
1170 if ( false !== strpos( $block_content, 'data-recipient-sig=' ) ) {
1171 $block_content = preg_replace(
1172 '/\sdata-recipient-sig=(["\'])[^"\']*\1/',
1173 ' data-recipient-sig="' . esc_attr( $sig ) . '"',
1174 $block_content,
1175 1
1176 );
1177 } else {
1178 $block_content = preg_replace(
1179 '/^\s*(<[a-zA-Z][^>]*)/',
1180 '$1 data-recipient-sig="' . esc_attr( $sig ) . '"',
1181 $block_content,
1182 1
1183 );
1184 }
1185
1186 return $block_content;
1187 }, 10, 2 );
1188
1189 /**
1190 * Contact Form endpoint — POST /wp-json/blockenberg/v1/contact
1191 * Sends an email via wp_mail() to the admin or a custom recipient stored in the block.
1192 * Custom recipients require a valid server-issued HMAC (data-recipient-sig).
1193 */
1194 add_action( 'rest_api_init', function () {
1195 register_rest_route( 'blockenberg/v1', '/contact', array(
1196 'methods' => 'POST',
1197 'permission_callback' => '__return_true',
1198 'callback' => function ( WP_REST_Request $request ) {
1199 $payload = $request->get_json_params();
1200 if ( ! is_array( $payload ) ) {
1201 $payload = array();
1202 }
1203
1204 // Rate-limit by IP: max 5 requests per 60 s.
1205 $ip = '';
1206 if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
1207 $ip = sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) );
1208 }
1209 if ( '' !== $ip ) {
1210 $rl_key = 'bkbg_contact_' . md5( $ip );
1211 $rl = get_transient( $rl_key );
1212 if ( is_array( $rl ) && isset( $rl['count'] ) && $rl['count'] >= 5 ) {
1213 return new WP_Error( 'rate_limited', __( 'Too many requests. Please try again later.', 'blockenberg' ), array( 'status' => 429 ) );
1214 }
1215 if ( is_array( $rl ) ) {
1216 $rl['count']++;
1217 set_transient( $rl_key, $rl, 60 );
1218 } else {
1219 set_transient( $rl_key, array( 'count' => 1 ), 60 );
1220 }
1221 }
1222
1223 // Honeypot check (bots fill the hidden website field).
1224 $honeypot = isset( $payload['website'] ) ? sanitize_text_field( (string) $payload['website'] ) : '';
1225 if ( '' !== $honeypot ) {
1226 return rest_ensure_response( array( 'ok' => true ) ); // silently accept.
1227 }
1228
1229 // Validate required fields.
1230 $name = isset( $payload['name'] ) ? sanitize_text_field( (string) $payload['name'] ) : '';
1231 $email = isset( $payload['email'] ) ? sanitize_email( (string) $payload['email'] ) : '';
1232 $phone = isset( $payload['phone'] ) ? sanitize_text_field( (string) $payload['phone'] ) : '';
1233 $message = isset( $payload['message'] ) ? sanitize_textarea_field( (string) $payload['message'] ) : '';
1234
1235 if ( ! is_email( $email ) ) {
1236 return new WP_Error( 'invalid_email', __( 'Invalid email address.', 'blockenberg' ), array( 'status' => 400 ) );
1237 }
1238 if ( empty( $message ) ) {
1239 return new WP_Error( 'empty_message', __( 'Message is required.', 'blockenberg' ), array( 'status' => 400 ) );
1240 }
1241
1242 // Recipient: only accept a client-supplied address when it carries a
1243 // valid server HMAC. Otherwise always fall back to admin_email (no open relay).
1244 $admin_email = sanitize_email( (string) get_option( 'admin_email' ) );
1245 $recipient = $admin_email;
1246 $requested = isset( $payload['recipient'] ) ? sanitize_email( (string) $payload['recipient'] ) : '';
1247 $sig = isset( $payload['recipientSig'] ) ? (string) $payload['recipientSig'] : '';
1248 if ( is_email( $requested ) && bkbg_verify_contact_recipient( $requested, $sig ) ) {
1249 $recipient = strtolower( $requested );
1250 }
1251
1252 $subject = isset( $payload['subject'] ) ? bkbg_sanitize_mail_header( (string) $payload['subject'] ) : '';
1253 if ( '' === $subject ) {
1254 $subject = __( 'New Contact Form Submission', 'blockenberg' );
1255 }
1256
1257 // Build email body.
1258 $body = "Name: {$name}\n";
1259 $body .= "Email: {$email}\n";
1260 if ( ! empty( $phone ) ) {
1261 $body .= "Phone: {$phone}\n";
1262 }
1263 $body .= "\nMessage:\n{$message}\n";
1264
1265 // Reply-To: strip CR/LF from name; never allow header injection.
1266 $safe_name = bkbg_sanitize_mail_header( $name );
1267 $safe_name = str_replace( array( '"', '<', '>' ), '', $safe_name );
1268 if ( '' !== $safe_name ) {
1269 $reply_to = sprintf( 'Reply-To: %s <%s>', $safe_name, $email );
1270 } else {
1271 $reply_to = 'Reply-To: ' . $email;
1272 }
1273
1274 $headers = array(
1275 'Content-Type: text/plain; charset=UTF-8',
1276 $reply_to,
1277 );
1278
1279 $sent = wp_mail( $recipient, $subject, $body, $headers );
1280
1281 if ( ! $sent ) {
1282 return new WP_Error( 'mail_failed', __( 'Failed to send email. Please try again.', 'blockenberg' ), array( 'status' => 500 ) );
1283 }
1284
1285 return rest_ensure_response( array( 'ok' => true ) );
1286 },
1287 ) );
1288 } );