PluginProbe
Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor / 2.0.11
Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor v2.0.11
2.0.13 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 & AI Agent for WordPress Block Editor 2.0.11, at blockenberg.php

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