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
blockenberg / blockenberg.php

blockenberg.php in Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor 2.0.14, at blockenberg.php

1,303 lines 55.9 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 — 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 * 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 // 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 /**
54 * Enqueue common editor styles and scripts for all Blockenberg blocks
55 */
56 add_action( 'enqueue_block_editor_assets', function() {
57 wp_enqueue_style( 'dashicons' );
58 // Ensure Media Library modal assets are available for blocks using MediaUpload.
59 wp_enqueue_media();
60
61 // Common editor scripts (branded icons)
62 $editor_js = __DIR__ . '/assets/js/editor.js';
63 if ( file_exists( $editor_js ) ) {
64 wp_enqueue_script(
65 'bkbg-editor-common',
66 plugins_url( 'assets/js/editor.js', __FILE__ ),
67 array( 'wp-blocks', 'wp-dom-ready', 'wp-element' ),
68 filemtime( $editor_js ),
69 true
70 );
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' );
88 });
89
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 /**
137 * Enqueue editor styles in a way compatible with the iframe-based editor canvas.
138 */
139 add_action( 'enqueue_block_assets', function () {
140 // Avoid loading editor-only CSS on the frontend.
141 if ( ! is_admin() ) {
142 return;
143 }
144
145 // Layout system CSS (shared variables and utilities)
146 $layout_css = __DIR__ . '/assets/css/layout.css';
147 if ( file_exists( $layout_css ) ) {
148 wp_enqueue_style(
149 'bkbg-layout-system',
150 plugins_url( 'assets/css/layout.css', __FILE__ ),
151 array(),
152 filemtime( $layout_css )
153 );
154 }
155
156 // Common editor styles for all blocks
157 $editor_css = __DIR__ . '/assets/css/editor.css';
158 if ( file_exists( $editor_css ) ) {
159 wp_enqueue_style(
160 'bkbg-editor-common',
161 plugins_url( 'assets/css/editor.css', __FILE__ ),
162 array( 'bkbg-layout-system' ),
163 filemtime( $editor_css )
164 );
165 }
166 } );
167
168 /**
169 * Register block scripts with dependencies (but don't enqueue them yet)
170 */
171 add_action( 'init', function () {
172 $blocks_dir = __DIR__ . '/blocks/';
173
174 // Standard WordPress script dependencies for blocks
175 $script_dependencies = array(
176 'wp-blocks',
177 'wp-element',
178 'wp-i18n',
179 'wp-block-editor',
180 'wp-components',
181 'wp-dom-ready',
182 'wp-data',
183 'bkbg-inspector-tabs',
184 'bkbg-typography-control',
185 'bkbg-icon-picker'
186 );
187
188 // Standard WordPress style dependencies for blocks
189 $style_dependencies = array(
190 'dashicons'
191 );
192
193 // Register layout system CSS for frontend
194 $layout_css = __DIR__ . '/assets/css/layout.css';
195 if ( file_exists( $layout_css ) ) {
196 wp_register_style(
197 'bkbg-layout-system',
198 plugins_url( 'assets/css/layout.css', __FILE__ ),
199 array(),
200 filemtime( $layout_css )
201 );
202 }
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
210 // Automatically register scripts for all blocks
211 foreach ( glob( $blocks_dir . '*', GLOB_ONLYDIR ) as $block_dir ) {
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
219 $script_file = $block_dir . '/index.js';
220
221 $style_file = $block_dir . '/style.css';
222 $frontend_file = $block_dir . '/frontend.js';
223
224 // Check if block has a JavaScript file
225 if ( file_exists( $script_file ) ) {
226 wp_register_script(
227 'bkbg-' . $block_name . '-editor',
228 plugins_url( 'blocks/' . $block_name . '/index.js', __FILE__ ),
229 $script_dependencies,
230 filemtime( $script_file ),
231 true
232 );
233 }
234
235 // Check if block has a CSS file
236 if ( file_exists( $style_file ) ) {
237 // Layout blocks need the layout system CSS
238 $block_style_deps = $style_dependencies;
239 if ( in_array( $block_name, array( 'section', 'row', 'column' ), true ) ) {
240 $block_style_deps[] = 'bkbg-layout-system';
241 }
242
243 wp_register_style(
244 'bkbg-' . $block_name . '-style',
245 plugins_url( 'blocks/' . $block_name . '/style.css', __FILE__ ),
246 $block_style_deps,
247 filemtime( $style_file )
248 );
249 }
250
251 // Check if block has a frontend JavaScript file
252 if ( file_exists( $frontend_file ) ) {
253 wp_register_script(
254 'bkbg-' . $block_name . '-frontend',
255 plugins_url( 'blocks/' . $block_name . '/frontend.js', __FILE__ ),
256 array( 'wp-dom-ready', 'bkbg-icon-picker-frontend' ),
257 filemtime( $frontend_file ),
258 true
259 );
260 }
261 }
262
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
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
280 register_block_type( dirname( $metadata ) );
281 }
282 } );
283
284 // Register custom block category and ensure Blockenberg blocks appear in it.
285 add_filter( 'block_categories_all', function( $categories, $block_editor_context ) {
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 }
304 return $categories;
305 }, 10, 2 );
306
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 /**
923 * Register REST API endpoint for Post Grid block.
924 *
925 * Route: /wp-json/blockenberg/v1/post-grid
926 * Method: GET
927 * Params:
928 * - type: string (posts|pages|any CPT)
929 * - orderby: string (date|title|comment_count|...)
930 * - order: string (asc|desc)
931 * - per_page: int
932 * - offset: int
933 * - page: int (1-based)
934 * - excerpt_len: int (optional)
935 */
936 add_action( 'rest_api_init', function () {
937 register_rest_route( 'blockenberg/v1', '/post-grid', array(
938 'methods' => 'GET',
939 'permission_callback' => '__return_true',
940 'args' => array(
941 'type' => array( 'sanitize_callback' => 'sanitize_key' ),
942 'orderby' => array( 'sanitize_callback' => 'sanitize_key' ),
943 'order' => array( 'sanitize_callback' => 'sanitize_text_field' ),
944 'per_page' => array( 'sanitize_callback' => 'absint' ),
945 'offset' => array( 'sanitize_callback' => 'absint' ),
946 'page' => array( 'sanitize_callback' => 'absint' ),
947 'excerpt_len' => array( 'sanitize_callback' => 'absint' ),
948 ),
949 'callback' => function ( WP_REST_Request $request ) {
950 $requested_type = sanitize_key( $request->get_param( 'type' ) ?: 'post' );
951 if ( 'posts' === $requested_type ) {
952 $requested_type = 'post';
953 } elseif ( 'pages' === $requested_type ) {
954 $requested_type = 'page';
955 }
956
957 $public_post_types = get_post_types( array( 'public' => true ), 'names' );
958 $post_type = in_array( $requested_type, $public_post_types, true ) ? $requested_type : 'post';
959
960 $requested_orderby = sanitize_key( $request->get_param( 'orderby' ) ?: 'date' );
961 $allowed_orderby = array( 'date', 'title', 'modified', 'comment_count', 'rand', 'menu_order' );
962 $orderby = in_array( $requested_orderby, $allowed_orderby, true ) ? $requested_orderby : 'date';
963 $order = strtolower( (string) $request->get_param( 'order' ) ) === 'asc' ? 'ASC' : 'DESC';
964 $per_page = max( 1, absint( $request->get_param( 'per_page' ) ?: 6 ) );
965 $per_page = min( 50, $per_page );
966 $offset = max( 0, absint( $request->get_param( 'offset' ) ?: 0 ) );
967 $offset = min( 5000, $offset );
968 $page = max( 1, absint( $request->get_param( 'page' ) ?: 1 ) );
969 $page = min( 200, $page );
970 $excerpt_len = max( 5, absint( $request->get_param( 'excerpt_len' ) ?: 18 ) );
971 $excerpt_len = min( 80, $excerpt_len );
972
973 // Short cache for public non-product queries.
974 $is_product_query = ( 'product' === $post_type );
975 $cache_key = '';
976 if ( ! $is_product_query ) {
977 $cache_key = 'bkbg_post_grid_' . md5( wp_json_encode( array(
978 'type' => $post_type,
979 'orderby' => $orderby,
980 'order' => $order,
981 'per_page' => $per_page,
982 'offset' => $offset,
983 'page' => $page,
984 'excerpt_len' => $excerpt_len,
985 ) ) );
986 $cached = get_transient( $cache_key );
987 if ( is_array( $cached ) ) {
988 return rest_ensure_response( $cached );
989 }
990 }
991
992 $q = new WP_Query( array(
993 'post_type' => $post_type,
994 'post_status' => 'publish',
995 'orderby' => $orderby,
996 'order' => $order,
997 'posts_per_page' => $per_page,
998 'offset' => $offset + ( ( $page - 1 ) * $per_page ),
999 'ignore_sticky_posts' => true,
1000 'no_found_rows' => true,
1001 ) );
1002
1003 $posts = array();
1004 foreach ( $q->posts as $p ) {
1005 $post_id = $p->ID;
1006 $title = wp_strip_all_tags( get_the_title( $post_id ) );
1007 $link = esc_url_raw( get_permalink( $post_id ) );
1008 $image = esc_url_raw( (string) get_the_post_thumbnail_url( $post_id, 'medium_large' ) );
1009 $date = wp_strip_all_tags( (string) get_the_date( '', $post_id ) );
1010 $author = sanitize_text_field( (string) get_the_author_meta( 'display_name', $p->post_author ) );
1011 $meta = trim( $date . ' · ' . $author );
1012 $raw = get_post_field( 'post_excerpt', $post_id );
1013 if ( '' === $raw ) {
1014 $raw = get_post_field( 'post_content', $post_id );
1015 }
1016 $excerpt = wp_strip_all_tags( wp_trim_words( wp_strip_all_tags( $raw ), $excerpt_len, '…' ) );
1017
1018 $item = array(
1019 'id' => $post_id,
1020 'title' => $title,
1021 'link' => $link,
1022 'image' => $image,
1023 'meta' => $meta,
1024 'excerpt' => $excerpt,
1025 );
1026
1027 if ( 'product' === $post_type && function_exists( 'wc_get_product' ) ) {
1028 $product = wc_get_product( $post_id );
1029 if ( $product ) {
1030 $item['price_html'] = wp_kses_post( $product->get_price_html() );
1031 $item['add_to_cart'] = esc_url_raw( $product->add_to_cart_url() );
1032 }
1033 }
1034
1035 $posts[] = $item;
1036 }
1037
1038 $payload = array( 'posts' => $posts );
1039
1040 if ( ! $is_product_query && '' !== $cache_key ) {
1041 set_transient( $cache_key, $payload, 60 );
1042 }
1043
1044 return rest_ensure_response( $payload );
1045 },
1046 ) );
1047 } );
1048
1049 /**
1050 * Simple local newsletter subscribe endpoint.
1051 * Stores emails in an option array; extend as needed (e.g., to a custom table).
1052 */
1053 add_action( 'rest_api_init', function () {
1054 register_rest_route( 'blockenberg/v1', '/subscribe', array(
1055 'methods' => 'POST',
1056 'permission_callback' => '__return_true',
1057 'args' => array(
1058 'email' => array( 'sanitize_callback' => 'sanitize_email' ),
1059 'website' => array( 'sanitize_callback' => 'sanitize_text_field' ),
1060 ),
1061 'callback' => function ( WP_REST_Request $request ) {
1062 $payload = $request->get_json_params();
1063 if ( ! is_array( $payload ) ) {
1064 $payload = array();
1065 }
1066
1067 // Basic rate limiting by IP to reduce spam / abuse.
1068 $ip = '';
1069 if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
1070 $ip = sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) );
1071 }
1072 if ( '' !== $ip ) {
1073 $key = 'bkbg_subscribe_' . md5( $ip );
1074 $rl = get_transient( $key );
1075 if ( is_array( $rl ) && isset( $rl['count'], $rl['start'] ) ) {
1076 if ( $rl['count'] >= 5 ) {
1077 return new WP_Error( 'rate_limited', __( 'Too many requests. Please try again later.', 'blockenberg' ), array( 'status' => 429 ) );
1078 }
1079 $rl['count']++;
1080 set_transient( $key, $rl, 60 );
1081 } else {
1082 set_transient( $key, array( 'count' => 1, 'start' => time() ), 60 );
1083 }
1084 }
1085
1086 // Optional honeypot field (bots tend to fill it).
1087 $honeypot = isset( $payload['website'] ) ? sanitize_text_field( (string) $payload['website'] ) : '';
1088 if ( '' !== $honeypot ) {
1089 return rest_ensure_response( array( 'ok' => true ) );
1090 }
1091
1092 $email = isset( $payload['email'] ) ? sanitize_email( (string) $payload['email'] ) : '';
1093 if ( empty( $email ) || ! is_email( $email ) ) {
1094 return new WP_Error( 'invalid_email', __( 'Invalid email address', 'blockenberg' ), array( 'status' => 400 ) );
1095 }
1096
1097 $list = get_option( 'blockenberg_newsletter_subscribers', array() );
1098 if ( ! is_array( $list ) ) {
1099 $list = array();
1100 }
1101
1102 // Prevent unbounded option growth.
1103 if ( count( $list ) >= 5000 ) {
1104 return new WP_Error( 'storage_full', __( 'Subscriber list is full.', 'blockenberg' ), array( 'status' => 503 ) );
1105 }
1106
1107 if ( ! in_array( $email, $list, true ) ) {
1108 $list[] = $email;
1109 update_option( 'blockenberg_newsletter_subscribers', $list, false );
1110 }
1111 return rest_ensure_response( array( 'ok' => true ) );
1112 },
1113 ) );
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 } );
1303