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