PluginProbe
Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor / 2.0.13
Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor v2.0.13
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 +385 -12 2.0.62.0.13 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.6
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.13
6 6 * Author: Blockenberg
7 7 * Text Domain: blockenberg
8 8 * Domain Path: /languages
9 9 * License: GPLv2 or later
@@ -12,8 +12,38 @@
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 +/**
28 + * User Field block — dynamic PHP render for logged-in profile values.
29 + */
30 +require_once __DIR__ . '/blocks/user-field/render.php';
31 +
32 +/**
33 + * AI Agent — OpenRouter-powered chat panel inside the block editor.
34 + */
35 +require_once __DIR__ . '/assets/php/ai-assistant.php';
36 +require_once __DIR__ . '/assets/php/ai-external.php';
37 +
38 +/**
39 + * Admin dashboard — Block Manager (enable / disable blocks).
40 + */
41 +if ( is_admin() ) {
42 + require_once __DIR__ . '/assets/php/admin-dashboard.php';
43 +}
44 +
45 +/**
16 46 * Enqueue common editor styles and scripts for all Blockenberg blocks
17 47 */
18 48 add_action( 'enqueue_block_editor_assets', function() {
19 49 wp_enqueue_style( 'dashicons' );
@@ -42,11 +72,61 @@
42 72 filemtime( $inspector_tabs_js ),
43 73 true
44 74 );
45 75 }
76 +
77 + // Typography Control — shared Elementor-like popover for all blocks
78 + // Registered on init (so block scripts can safely depend on it).
79 + wp_enqueue_script( 'bkbg-typography-control' );
46 80 });
47 81
48 82 /**
83 + * Register shared editor scripts early (so other scripts can list them as deps).
84 + */
85 +add_action( 'init', function () {
86 + $typo_js = __DIR__ . '/assets/js/typography-control.js';
87 + if ( ! file_exists( $typo_js ) ) {
88 + return;
89 + }
90 +
91 + wp_register_script(
92 + 'bkbg-typography-control',
93 + plugins_url( 'assets/js/typography-control.js', __FILE__ ),
94 + array( 'wp-element', 'wp-components', 'wp-i18n' ),
95 + filemtime( $typo_js ),
96 + true
97 + );
98 +
99 + // Pass Google Fonts list to JS as window.bkbgGoogleFonts
100 + wp_localize_script(
101 + 'bkbg-typography-control',
102 + 'bkbgGoogleFonts',
103 + function_exists( 'bkbg_google_fonts_list' ) ? bkbg_google_fonts_list() : array()
104 + );
105 +
106 + // Icon Picker — shared icon type selector + dashicon picker for all blocks
107 + $icon_picker_js = __DIR__ . '/assets/js/icon-picker.js';
108 + if ( file_exists( $icon_picker_js ) ) {
109 + // Editor handle (needs WP component deps for UI)
110 + wp_register_script(
111 + 'bkbg-icon-picker',
112 + plugins_url( 'assets/js/icon-picker.js', __FILE__ ),
113 + array( 'wp-element', 'wp-components', 'wp-i18n' ),
114 + filemtime( $icon_picker_js ),
115 + true
116 + );
117 + // Frontend handle (same file, no WP deps — only data + DOM builder)
118 + wp_register_script(
119 + 'bkbg-icon-picker-frontend',
120 + plugins_url( 'assets/js/icon-picker.js', __FILE__ ),
121 + array(),
122 + filemtime( $icon_picker_js ),
123 + true
124 + );
125 + }
126 +} );
127 +
128 +/**
49 129 * Enqueue editor styles in a way compatible with the iframe-based editor canvas.
50 130 */
51 131 add_action( 'enqueue_block_assets', function () {
52 132 // Avoid loading editor-only CSS on the frontend.
@@ -90,9 +170,12 @@
90 170 'wp-i18n',
91 171 'wp-block-editor',
92 172 'wp-components',
93 173 'wp-dom-ready',
94 - 'wp-data'
174 + 'wp-data',
175 + 'bkbg-inspector-tabs',
176 + 'bkbg-typography-control',
177 + 'bkbg-icon-picker'
95 178 );
96 179
97 180 // Standard WordPress style dependencies for blocks
98 181 $style_dependencies = array(
@@ -109,11 +192,23 @@
109 192 filemtime( $layout_css )
110 193 );
111 194 }
112 195
196 + // Get disabled blocks list to skip asset registration.
197 + $disabled_blocks = get_option( 'blockenberg_disabled_blocks', array() );
198 + if ( ! is_array( $disabled_blocks ) ) {
199 + $disabled_blocks = array();
200 + }
201 +
113 202 // Automatically register scripts for all blocks
114 203 foreach ( glob( $blocks_dir . '*', GLOB_ONLYDIR ) as $block_dir ) {
115 204 $block_name = basename( $block_dir );
205 +
206 + // Skip disabled blocks.
207 + if ( in_array( 'blockenberg/' . $block_name, $disabled_blocks, true ) ) {
208 + continue;
209 + }
210 +
116 211 $script_file = $block_dir . '/index.js';
117 212
118 213 $style_file = $block_dir . '/style.css';
119 214 $frontend_file = $block_dir . '/frontend.js';
@@ -149,9 +244,9 @@
149 244 if ( file_exists( $frontend_file ) ) {
150 245 wp_register_script(
151 246 'bkbg-' . $block_name . '-frontend',
152 247 plugins_url( 'blocks/' . $block_name . '/frontend.js', __FILE__ ),
153 - array(),
248 + array( 'wp-dom-ready', 'bkbg-icon-picker-frontend' ),
154 249 filemtime( $frontend_file ),
155 250 true
156 251 );
157 252 }
@@ -157,9 +252,24 @@
157 252 }
158 253 }
159 254
160 255 // Automatically register all blocks in the /blocks directory
256 + // Skip blocks the admin has disabled via the Blockenberg dashboard.
257 + $disabled_blocks = get_option( 'blockenberg_disabled_blocks', array() );
258 + if ( ! is_array( $disabled_blocks ) ) {
259 + $disabled_blocks = array();
260 + }
261 +
161 262 foreach ( glob( __DIR__ . '/blocks/*/block.json' ) as $metadata ) {
263 + // Read block name from block.json to check against disabled list.
264 + $raw_json = file_get_contents( $metadata );
265 + $block_meta = $raw_json ? json_decode( $raw_json, true ) : null;
266 + $block_name = is_array( $block_meta ) && isset( $block_meta['name'] ) ? $block_meta['name'] : '';
267 +
268 + if ( '' !== $block_name && in_array( $block_name, $disabled_blocks, true ) ) {
269 + continue; // Block is disabled — skip registration.
270 + }
271 +
162 272 register_block_type( dirname( $metadata ) );
163 273 }
164 274 } );
165 275
@@ -164,18 +274,84 @@
164 274 } );
165 275
166 276 // Register custom block category and ensure Blockenberg blocks appear in it.
167 277 add_filter( 'block_categories_all', function( $categories, $block_editor_context ) {
168 - // Prepend our custom category so it appears first.
169 - array_unshift( $categories, array(
170 - 'slug' => 'blockenberg',
171 - 'title' => __( 'Blockenberg Blocks', 'blockenberg' ),
172 - 'icon' => null,
173 - ) );
278 + // Prepend Blockenberg sub-categories in reverse order so they appear in the right order.
279 + $bkbg_categories = array(
280 + array( 'slug' => 'blockenberg', 'title' => __( 'General (Blockenberg)', 'blockenberg' ), 'icon' => null ),
281 + array( 'slug' => 'bkbg-layout', 'title' => __( 'Layout & Structure (Blockenberg)', 'blockenberg' ), 'icon' => null ),
282 + array( 'slug' => 'bkbg-content', 'title' => __( 'Content & Typography (Blockenberg)', 'blockenberg' ), 'icon' => null ),
283 + array( 'slug' => 'bkbg-media', 'title' => __( 'Media & Images (Blockenberg)', 'blockenberg' ), 'icon' => null ),
284 + array( 'slug' => 'bkbg-marketing', 'title' => __( 'Marketing & Conversion (Blockenberg)','blockenberg' ), 'icon' => null ),
285 + array( 'slug' => 'bkbg-business', 'title' => __( 'Business & Services (Blockenberg)', 'blockenberg' ), 'icon' => null ),
286 + array( 'slug' => 'bkbg-blog', 'title' => __( 'Blog & Editorial (Blockenberg)', 'blockenberg' ), 'icon' => null ),
287 + array( 'slug' => 'bkbg-interactive', 'title' => __( 'Interactive & Games (Blockenberg)', 'blockenberg' ), 'icon' => null ),
288 + array( 'slug' => 'bkbg-charts', 'title' => __( 'Charts & Data (Blockenberg)', 'blockenberg' ), 'icon' => null ),
289 + array( 'slug' => 'bkbg-calculators', 'title' => __( 'Calculators & Tools (Blockenberg)', 'blockenberg' ), 'icon' => null ),
290 + array( 'slug' => 'bkbg-effects', 'title' => __( 'Effects & Animation (Blockenberg)', 'blockenberg' ), 'icon' => null ),
291 + array( 'slug' => 'bkbg-dev', 'title' => __( 'Developer Tools (Blockenberg)', 'blockenberg' ), 'icon' => null ),
292 + );
293 + foreach ( array_reverse( $bkbg_categories ) as $cat ) {
294 + array_unshift( $categories, $cat );
295 + }
174 296 return $categories;
175 297 }, 10, 2 );
176 298
177 299 /**
300 + * Load Google Fonts on the frontend for Blockenberg blocks.
301 + * Scans block attributes for 'headerTypo', 'contentTypo', and any other
302 + * attribute ending in 'Typo' that contains a non-empty 'family' key.
303 + */
304 +add_action( 'wp_enqueue_scripts', function () {
305 + if ( ! is_singular() ) {
306 + return;
307 + }
308 + $post = get_post();
309 + if ( ! $post || ! has_blocks( $post->post_content ) ) {
310 + return;
311 + }
312 +
313 + $system_fonts = array( 'Arial', 'Georgia', 'Helvetica', 'Tahoma', 'Times New Roman', 'Trebuchet MS', 'Verdana' );
314 + $queued = array();
315 +
316 + $blocks = parse_blocks( $post->post_content );
317 +
318 + // Recursive walker for nested blocks
319 + $collect = null;
320 + $collect = function ( $blocks ) use ( &$collect, $system_fonts, &$queued ) {
321 + foreach ( $blocks as $block ) {
322 + if ( strpos( (string) $block['blockName'], 'blockenberg/' ) !== 0 ) {
323 + if ( ! empty( $block['innerBlocks'] ) ) {
324 + $collect( $block['innerBlocks'] );
325 + }
326 + continue;
327 + }
328 + $attrs = $block['attrs'] ?? array();
329 + foreach ( $attrs as $key => $val ) {
330 + // Any typography attribute (legacy *Typo suffix OR new typo* prefix)
331 + // that is an array with a non-empty 'family' key.
332 + $is_typo_key = ( substr( $key, -4 ) === 'Typo' ) || ( strpos( $key, 'typo' ) === 0 );
333 + if ( $is_typo_key && is_array( $val ) && ! empty( $val['family'] ) ) {
334 + $family = sanitize_text_field( $val['family'] );
335 + if ( ! in_array( $family, $system_fonts, true ) && ! isset( $queued[ $family ] ) ) {
336 + $queued[ $family ] = true;
337 + $handle = 'bkbg-gf-' . sanitize_title( $family );
338 + $url = 'https://fonts.googleapis.com/css2?family=' .
339 + urlencode( $family ) .
340 + ':wght@300;400;500;600;700;800;900&display=swap';
341 + wp_enqueue_style( $handle, $url, array(), null );
342 + }
343 + }
344 + }
345 + if ( ! empty( $block['innerBlocks'] ) ) {
346 + $collect( $block['innerBlocks'] );
347 + }
348 + }
349 + };
350 + $collect( $blocks );
351 +} );
352 +
353 +/**
178 354 * Register advanced layout attributes on the SERVER side for every Blockenberg block.
179 355 * Without this, WordPress strips unknown attributes during server-side parsing
180 356 * (array_intersect_key in WP_Block_Type::prepare_attributes_for_render).
181 357 */
@@ -183,8 +359,17 @@
183 359 if ( strpos( $block_type, 'blockenberg/' ) !== 0 ) {
184 360 return $args;
185 361 }
186 362
363 + // Blockenberg has its own Advanced spacing controls.
364 + // Disable core Gutenberg "Dimensions" (spacing) UI to avoid duplicates.
365 + if ( isset( $args['supports'] ) && is_array( $args['supports'] ) ) {
366 + unset( $args['supports']['spacing'] );
367 + unset( $args['supports']['__experimentalSpacing'] );
368 + unset( $args['supports']['dimensions'] );
369 + unset( $args['supports']['__experimentalDimensions'] );
370 + }
371 +
187 372 $sides = array( 'Top', 'Right', 'Bottom', 'Left' );
188 373 $devices = array( '', 'Tablet', 'Mobile' );
189 374 $extra = array();
190 375
@@ -917,5 +1102,193 @@
917 1102 }
918 1103 return rest_ensure_response( array( 'ok' => true ) );
919 1104 },
920 1105 ) );
921 -} );
1106 +} );
1107 +
1108 +/**
1109 + * Strip CR/LF and related sequences so values cannot inject mail headers.
1110 + *
1111 + * @param string $value Raw header fragment.
1112 + * @return string
1113 + */
1114 +function bkbg_sanitize_mail_header( $value ) {
1115 + $value = (string) $value;
1116 + $value = str_replace( array( "\r", "\n", '%0a', '%0d', '%0A', '%0D' ), '', $value );
1117 + return trim( sanitize_text_field( $value ) );
1118 +}
1119 +
1120 +/**
1121 + * HMAC signature for a contact-form recipient email.
1122 + * Prevents open-relay abuse: the client may only use a recipient that was
1123 + * signed server-side when the block was rendered.
1124 + *
1125 + * @param string $email Recipient email.
1126 + * @return string Hex HMAC or empty string if invalid.
1127 + */
1128 +function bkbg_contact_recipient_sig( $email ) {
1129 + $email = strtolower( sanitize_email( (string) $email ) );
1130 + if ( ! is_email( $email ) ) {
1131 + return '';
1132 + }
1133 + return hash_hmac( 'sha256', $email, wp_salt( 'auth' ) );
1134 +}
1135 +
1136 +/**
1137 + * Verify a contact-form recipient signature.
1138 + *
1139 + * @param string $email Recipient email from the client.
1140 + * @param string $sig HMAC from data-recipient-sig.
1141 + * @return bool
1142 + */
1143 +function bkbg_verify_contact_recipient( $email, $sig ) {
1144 + $email = strtolower( sanitize_email( (string) $email ) );
1145 + if ( ! is_email( $email ) || ! is_string( $sig ) || '' === $sig ) {
1146 + return false;
1147 + }
1148 + $expected = bkbg_contact_recipient_sig( $email );
1149 + return ( '' !== $expected && hash_equals( $expected, $sig ) );
1150 +}
1151 +
1152 +/**
1153 + * Inject a server-signed recipient HMAC into Contact Form markup on render.
1154 + * Existing posts do not need to be re-saved — the signature is added at runtime.
1155 + */
1156 +add_filter( 'render_block', function ( $block_content, $block ) {
1157 + if ( empty( $block['blockName'] ) || 'blockenberg/contact-form' !== $block['blockName'] ) {
1158 + return $block_content;
1159 + }
1160 + if ( ! is_string( $block_content ) || '' === $block_content ) {
1161 + return $block_content;
1162 + }
1163 +
1164 + $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
1165 + $recipient = isset( $attrs['recipientEmail'] ) ? sanitize_email( (string) $attrs['recipientEmail'] ) : '';
1166 + if ( ! is_email( $recipient ) ) {
1167 + return $block_content;
1168 + }
1169 +
1170 + $sig = bkbg_contact_recipient_sig( $recipient );
1171 + if ( '' === $sig ) {
1172 + return $block_content;
1173 + }
1174 +
1175 + // Replace existing sig if present, otherwise inject onto the first opening tag.
1176 + if ( false !== strpos( $block_content, 'data-recipient-sig=' ) ) {
1177 + $block_content = preg_replace(
1178 + '/\sdata-recipient-sig=(["\'])[^"\']*\1/',
1179 + ' data-recipient-sig="' . esc_attr( $sig ) . '"',
1180 + $block_content,
1181 + 1
1182 + );
1183 + } else {
1184 + $block_content = preg_replace(
1185 + '/^\s*(<[a-zA-Z][^>]*)/',
1186 + '$1 data-recipient-sig="' . esc_attr( $sig ) . '"',
1187 + $block_content,
1188 + 1
1189 + );
1190 + }
1191 +
1192 + return $block_content;
1193 +}, 10, 2 );
1194 +
1195 +/**
1196 + * Contact Form endpoint — POST /wp-json/blockenberg/v1/contact
1197 + * Sends an email via wp_mail() to the admin or a custom recipient stored in the block.
1198 + * Custom recipients require a valid server-issued HMAC (data-recipient-sig).
1199 + */
1200 +add_action( 'rest_api_init', function () {
1201 + register_rest_route( 'blockenberg/v1', '/contact', array(
1202 + 'methods' => 'POST',
1203 + 'permission_callback' => '__return_true',
1204 + 'callback' => function ( WP_REST_Request $request ) {
1205 + $payload = $request->get_json_params();
1206 + if ( ! is_array( $payload ) ) {
1207 + $payload = array();
1208 + }
1209 +
1210 + // Rate-limit by IP: max 5 requests per 60 s.
1211 + $ip = '';
1212 + if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
1213 + $ip = sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) );
1214 + }
1215 + if ( '' !== $ip ) {
1216 + $rl_key = 'bkbg_contact_' . md5( $ip );
1217 + $rl = get_transient( $rl_key );
1218 + if ( is_array( $rl ) && isset( $rl['count'] ) && $rl['count'] >= 5 ) {
1219 + return new WP_Error( 'rate_limited', __( 'Too many requests. Please try again later.', 'blockenberg' ), array( 'status' => 429 ) );
1220 + }
1221 + if ( is_array( $rl ) ) {
1222 + $rl['count']++;
1223 + set_transient( $rl_key, $rl, 60 );
1224 + } else {
1225 + set_transient( $rl_key, array( 'count' => 1 ), 60 );
1226 + }
1227 + }
1228 +
1229 + // Honeypot check (bots fill the hidden website field).
1230 + $honeypot = isset( $payload['website'] ) ? sanitize_text_field( (string) $payload['website'] ) : '';
1231 + if ( '' !== $honeypot ) {
1232 + return rest_ensure_response( array( 'ok' => true ) ); // silently accept.
1233 + }
1234 +
1235 + // Validate required fields.
1236 + $name = isset( $payload['name'] ) ? sanitize_text_field( (string) $payload['name'] ) : '';
1237 + $email = isset( $payload['email'] ) ? sanitize_email( (string) $payload['email'] ) : '';
1238 + $phone = isset( $payload['phone'] ) ? sanitize_text_field( (string) $payload['phone'] ) : '';
1239 + $message = isset( $payload['message'] ) ? sanitize_textarea_field( (string) $payload['message'] ) : '';
1240 +
1241 + if ( ! is_email( $email ) ) {
1242 + return new WP_Error( 'invalid_email', __( 'Invalid email address.', 'blockenberg' ), array( 'status' => 400 ) );
1243 + }
1244 + if ( empty( $message ) ) {
1245 + return new WP_Error( 'empty_message', __( 'Message is required.', 'blockenberg' ), array( 'status' => 400 ) );
1246 + }
1247 +
1248 + // Recipient: only accept a client-supplied address when it carries a
1249 + // valid server HMAC. Otherwise always fall back to admin_email (no open relay).
1250 + $admin_email = sanitize_email( (string) get_option( 'admin_email' ) );
1251 + $recipient = $admin_email;
1252 + $requested = isset( $payload['recipient'] ) ? sanitize_email( (string) $payload['recipient'] ) : '';
1253 + $sig = isset( $payload['recipientSig'] ) ? (string) $payload['recipientSig'] : '';
1254 + if ( is_email( $requested ) && bkbg_verify_contact_recipient( $requested, $sig ) ) {
1255 + $recipient = strtolower( $requested );
1256 + }
1257 +
1258 + $subject = isset( $payload['subject'] ) ? bkbg_sanitize_mail_header( (string) $payload['subject'] ) : '';
1259 + if ( '' === $subject ) {
1260 + $subject = __( 'New Contact Form Submission', 'blockenberg' );
1261 + }
1262 +
1263 + // Build email body.
1264 + $body = "Name: {$name}\n";
1265 + $body .= "Email: {$email}\n";
1266 + if ( ! empty( $phone ) ) {
1267 + $body .= "Phone: {$phone}\n";
1268 + }
1269 + $body .= "\nMessage:\n{$message}\n";
1270 +
1271 + // Reply-To: strip CR/LF from name; never allow header injection.
1272 + $safe_name = bkbg_sanitize_mail_header( $name );
1273 + $safe_name = str_replace( array( '"', '<', '>' ), '', $safe_name );
1274 + if ( '' !== $safe_name ) {
1275 + $reply_to = sprintf( 'Reply-To: %s <%s>', $safe_name, $email );
1276 + } else {
1277 + $reply_to = 'Reply-To: ' . $email;
1278 + }
1279 +
1280 + $headers = array(
1281 + 'Content-Type: text/plain; charset=UTF-8',
1282 + $reply_to,
1283 + );
1284 +
1285 + $sent = wp_mail( $recipient, $subject, $body, $headers );
1286 +
1287 + if ( ! $sent ) {
1288 + return new WP_Error( 'mail_failed', __( 'Failed to send email. Please try again.', 'blockenberg' ), array( 'status' => 500 ) );
1289 + }
1290 +
1291 + return rest_ensure_response( array( 'ok' => true ) );
1292 + },
1293 + ) );
1294 +} );