PluginProbe
TableKit – WordPress Table Builder for Data Tables, WooCommerce Product Tables & Post Tables / 2.3.0
TableKit – WordPress Table Builder for Data Tables, WooCommerce Product Tables & Post Tables v2.3.0
2.3.0 2.2.14 2.2.13 2.2.12 2.2.11 2.2.10 2.2.9 2.2.8 2.2.7 2.2.6 2.2.5 2.2.4 2.2.3 trunk 1.0.0 1.0.1 2.0.0 2.0.1 2.1.0 2.1.1 2.1.2 2.2.0 2.2.1 2.2.2
table-builder-block / includes / Shortcode / Shortcode.php

Shortcode.php in TableKit – WordPress Table Builder for Data Tables, WooCommerce Product Tables & Post Tables 2.3.0, at includes/Shortcode/Shortcode.php

563 lines 20.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The [tableKit] shortcode
4 *
5 * @package TableKit
6 */
7
8 namespace TableBuilder\Shortcode;
9
10 use TableBuilder\Hooks\AssetGenerator;
11 use TableBuilder\Shortcode\ShortcodeUtils;
12 use TableBuilder\Render\BlockRenderer;
13 use TableBuilder\Traits\Singleton;
14
15 defined( 'ABSPATH' ) || exit;
16
17 /**
18 * Handles registration and rendering of the [tableKit] shortcode.
19 */
20 class Shortcode {
21
22
23 use Singleton;
24
25 /**
26 * Whether frontend assets have been enqueued at least once.
27 *
28 * @var bool
29 */
30 private static bool $assets_enqueued = false;
31
32 // Default shortcode attribute values.
33 private const DEFAULT_ATTS = array(
34 'id' => '',
35 'post_id' => 0,
36 'block_id' => '',
37 'class' => '',
38 'attrs_json' => '{}',
39 );
40
41 // Default block attribute values.
42 private const DEFAULT_BLOCK_ATTRS = array(
43 'headers' => array(),
44 'footers' => array(),
45 'hasHeader' => true,
46 'hasFooter' => false,
47 'columnCount' => 0,
48 );
49
50 /**
51 * Hooks shortcode registration into WordPress init.
52 */
53 protected function __construct() {
54 add_action( 'init', array( $this, 'register_shortcodes' ) );
55 }
56
57 /**
58 * Registers all shortcodes for this plugin.
59 *
60 * @return void
61 */
62 public function register_shortcodes(): void {
63 add_shortcode( 'tableKit', array( $this, 'render' ) );
64 }
65
66 /**
67 * [tableKit] shortcode callback. Renders either every table block found in
68 * the resolved source post (when no specific block/id is given) or a single
69 * resolved block.
70 *
71 * @param array|string $raw_atts Raw shortcode attributes as passed by WordPress.
72 * @param string $content Optional. Shortcode enclosed content (currently unused
73 * beyond being passed through to the block renderer).
74 * @return string Rendered HTML.
75 */
76 public function render( $raw_atts, string $content = '' ): string {
77 $atts = $this->sanitize_atts( shortcode_atts( self::DEFAULT_ATTS, (array) $raw_atts, 'tableKit' ) );
78 $ids = $this->parse_ids( $atts );
79
80 // The shortcode prints its own table CSS, so the generic late-CSS
81 // printer (AssetGenerator) must not print it a second time.
82 $asset_generator = AssetGenerator::instance();
83 $asset_generator->suspend_late_css();
84
85 ob_start();
86
87 try {
88 if ( empty( $ids['block_id'] ) && empty( $ids['raw_id'] ) ) {
89 echo $this->render_all_blocks( $ids, $atts, $content ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from the_content filter output and esc_*()-escaped pieces, see render_all_blocks().
90 return (string) ob_get_clean();
91 }
92
93 $block = $this->locate_block( $ids['post_id'], $ids['block_id'], $ids['raw_id'] );
94
95 if ( ! empty( $block ) ) {
96 echo $this->render_single_block( $block, $atts, $ids, $content ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- delegates to BlockRenderer::render_table_builder(), which escapes/kses's its own output.
97 }
98
99 return (string) ob_get_clean();
100 } finally {
101 $asset_generator->resume_late_css();
102 }
103 }
104
105 // -------------------------------------------------------------------------
106 // Multi-block rendering (no block_id given)
107 // -------------------------------------------------------------------------
108
109 /**
110 * Renders the source post/page content for post_id-only shortcodes.
111 * For standalone tablekit_table CPT entries, this preserves the original
112 * behavior of rendering the stored block content.
113 *
114 * @param array $ids Resolved ID set from parse_ids() (post_id/block_id/raw_id).
115 * @param array $atts Sanitized shortcode attributes.
116 * @param string $content Shortcode enclosed content (unused here).
117 * @return string Rendered HTML, or an empty string if the post can't be found/viewed.
118 */
119 private function render_all_blocks( array $ids, array $atts, string $content ): string {
120 if ( empty( $ids['post_id'] ) ) {
121 return '';
122 }
123
124 $post_id = (int) $ids['post_id'];
125 static $cleared = array();
126 if ( ShortcodeUtils::is_elementor_editor() && ! isset( $cleared[ $post_id ] ) ) {
127 clean_post_cache( $post_id );
128 $cleared[ $post_id ] = true;
129 }
130
131 $post = get_post( $post_id );
132
133 if ( ! $post || empty( $post->post_content ) || ! $this->can_view_post( $post ) ) {
134 return '';
135 }
136 $this->block_enqueue_assets();
137 $css = $this->collect_blocks_css_from_content( $post->post_content );
138 $style = $css ? '<style id="tbk-shortcode-' . absint( $post->ID ) . '">' . $css . '</style>' : '';
139
140 $previous_post = $GLOBALS['post'] ?? null;
141 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- required so the "the_content" filter (and any callback that reads $GLOBALS['post']/get_post()) sees the correct post context; restored immediately below via wp_reset_postdata()/the original value.
142 $GLOBALS['post'] = $post; // Ensure filters use the shortcode post context.
143 setup_postdata( $post );
144 $content = (string) apply_filters( 'the_content', $post->post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- "the_content" is a WordPress core filter, not a hook defined by this plugin.
145 wp_reset_postdata();
146 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring the previous global post value, not overriding it.
147 $GLOBALS['post'] = $previous_post;
148
149 return $style . $content . ShortcodeUtils::get_elementor_init_script();
150 }
151
152 // -------------------------------------------------------------------------
153 // Single-block rendering
154 // -------------------------------------------------------------------------
155
156 /**
157 * Renders a single resolved block. Used both by the multi-block path and the
158 * specific block_id path.
159 *
160 * @param array $block Parsed block array to render.
161 * @param array $atts Sanitized shortcode attributes.
162 * @param array $ids Resolved ID set from parse_ids().
163 * @param string $content Shortcode enclosed content, passed through to the "tablekit/render_block" filter.
164 * @return string Rendered HTML.
165 */
166 private function render_single_block( array $block, array $atts, array $ids, string $content ): string {
167 // Pro hooks handle their own block types.
168 /**
169 * Filters the rendered output for a single [tableKit] block, letting the
170 * Pro add-on (or third parties) render block types this plugin doesn't know about.
171 *
172 * @since Unknown
173 *
174 * @param string|null $output Rendered HTML to short-circuit with, or null to use the default renderer.
175 * @param array $block Parsed block array to render.
176 * @param array $atts Sanitized shortcode attributes.
177 * @param array $ids Resolved ID set from parse_ids().
178 * @param string $content Shortcode enclosed content.
179 */
180 // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores -- slash-namespaced hook name is this plugin's established public API (used by the Pro add-on); renaming would be a breaking change.
181 $output = apply_filters( 'tablekit/render_block', null, $block, $atts, $ids, $content );
182 if ( null !== $output ) {
183 return (string) $output;
184 }
185
186 $attrs = $this->prepare_attrs( $block, $atts, $ids['block_id'] );
187 $rows = $block['innerBlocks'] ?? array();
188 $css = ShortcodeUtils::collect_block_css( $block );
189 $this->block_enqueue_assets();
190 $style = $css ? '<style id="tb-' . esc_attr( $attrs['blockID'] ) . '">' . $css . '</style>' : '';
191
192 return $style . BlockRenderer::render_table_builder( $attrs, $rows, $content );
193 }
194
195 // -------------------------------------------------------------------------
196 // Attribute Preparation
197 // -------------------------------------------------------------------------
198
199 /**
200 * Resolves a block's final attribute set: applies any `attrs_json` shortcode
201 * override, assigns a stable block ID, appends an extra CSS class, and fills
202 * in default header/footer/column values.
203 *
204 * @param array $block Parsed block array (its stored `attrs` are the base).
205 * @param array $atts Sanitized shortcode attributes.
206 * @param string $block_id Resolved block ID (may be empty; falls back to the block's own ID or a generated UUID).
207 * @return array Final attribute array.
208 */
209 private function prepare_attrs( array $block, array $atts, string $block_id ): array {
210 $attrs = $block['attrs'] ?? array();
211 $rows = $block['innerBlocks'] ?? array();
212
213 // Override stored attributes with any inline JSON overrides.
214 $json_overrides = ShortcodeUtils::decode_json( $atts['attrs_json'] );
215 if ( $json_overrides ) {
216 $attrs = array_replace_recursive( $attrs, $json_overrides );
217 }
218
219 // Ensure a stable unique block ID.
220 if ( $block_id ) {
221 $attrs['blockID'] = $block_id;
222 } elseif ( empty( $attrs['blockID'] ) ) {
223 $attrs['blockID'] = 'tb-' . wp_generate_uuid4();
224 }
225
226 // Append any extra CSS class passed via shortcode.
227 if ( ! empty( $atts['class'] ) ) {
228 $extra_class = sanitize_html_class( $atts['class'] );
229 $attrs['blockClass'] = trim( ( $attrs['blockClass'] ?? '' ) . ' ' . $extra_class );
230 }
231
232 return $this->apply_block_defaults( $attrs, $rows );
233 }
234
235 /**
236 * Merges in default block attribute values, and generates placeholder header/footer
237 * cells when a table has headers/footers enabled but no cell data yet.
238 *
239 * @param array $attrs Block attributes to fill in defaults for.
240 * @param array $rows The block's inner row blocks, used to detect column count when needed.
241 * @return array Attribute array with defaults applied.
242 */
243 private function apply_block_defaults( array $attrs, array $rows ): array {
244 $attrs = array_replace_recursive( self::DEFAULT_BLOCK_ATTRS, $attrs );
245
246 $col_count = (int) $attrs['columnCount'];
247
248 if ( ! $col_count && $rows ) {
249 $col_count = $this->detect_column_count( $rows );
250 }
251
252 if ( $attrs['hasHeader'] && empty( $attrs['headers'] ) && $col_count ) {
253 $attrs['headers'] = $this->generate_placeholder_cells( $col_count, 'Header' );
254 }
255
256 if ( $attrs['hasFooter'] && empty( $attrs['footers'] ) && $col_count ) {
257 $attrs['footers'] = $this->generate_placeholder_cells( $col_count, 'Footer' );
258 }
259
260 return $attrs;
261 }
262
263 /**
264 * Counts columns from the first "tablebuilder/table-builder-row" block's inner blocks.
265 *
266 * @param array $rows Row blocks (a table block's innerBlocks).
267 * @return int Detected column count, or 0 if no row block is found.
268 */
269 private function detect_column_count( array $rows ): int {
270 foreach ( $rows as $row ) {
271 if ( 'tablebuilder/table-builder-row' === ( $row['blockName'] ?? '' ) ) {
272 return count( $row['innerBlocks'] ?? array() );
273 }
274 }
275
276 return 0;
277 }
278
279 /**
280 * Builds a list of placeholder cell arrays, e.g. "Header 1", "Header 2", ...
281 *
282 * @param int $count Number of cells to generate.
283 * @param string $prefix Label prefix, e.g. "Header" or "Footer".
284 * @return array[] Array of ['title' => string] cell descriptors.
285 */
286 private function generate_placeholder_cells( int $count, string $prefix ): array {
287 return array_map(
288 static fn( int $i ) => array( 'title' => "{$prefix} " . ( $i + 1 ) ),
289 range( 0, $count - 1 )
290 );
291 }
292
293 // -------------------------------------------------------------------------
294 // ID Resolution
295 // -------------------------------------------------------------------------
296
297 /**
298 * Resolves the "id" attribute (which may be a numeric post ID or an alphanumeric
299 * block ID) alongside the explicit "post_id"/"block_id" attributes into a single
300 * unambiguous ID set.
301 *
302 * @param array $atts Sanitized shortcode attributes.
303 * @return array{raw_id:string,post_id:int,block_id:string} Resolved IDs.
304 */
305 private function parse_ids( array $atts ): array {
306 $raw_id = trim( (string) ( $atts['id'] ?? '' ) );
307 $post_id = (int) ( $atts['post_id'] ?? 0 );
308 $block_id = (string) ( $atts['block_id'] ?? '' );
309
310 if ( ! $post_id && ctype_digit( $raw_id ) ) {
311 $post_id = (int) $raw_id;
312 }
313
314 if ( ! $block_id && $raw_id && ! ctype_digit( $raw_id ) ) {
315 $block_id = $raw_id;
316 }
317
318 return compact( 'raw_id', 'post_id', 'block_id' );
319 }
320
321 // -------------------------------------------------------------------------
322 // Block Resolution (specific block_id path only)
323 // -------------------------------------------------------------------------
324
325 /**
326 * Locates a target block, either within a specific post (when $post_id is given)
327 * or by searching the database for any post containing the block ID.
328 *
329 * @param int $post_id Post ID to look within first, or 0 to skip straight to a database search.
330 * @param string $block_id Explicit block ID to search for.
331 * @param string $raw_id Fallback ID (from the shortcode's "id" attribute) used when $block_id is empty.
332 * @return array The located block, or an empty array if not found.
333 */
334 private function locate_block( int $post_id, string $block_id, string $raw_id ): array {
335 if ( $post_id ) {
336 $block = $this->get_block_from_post( $post_id, $block_id );
337 if ( $block ) {
338 return $block;
339 }
340 }
341
342 $lookup = $block_id ? $block_id : $raw_id;
343
344 return $lookup ? $this->search_block_by_id( $lookup ) : array();
345 }
346
347 /**
348 * Parses a specific post's block content and finds the target block.
349 *
350 * @param int $post_id Post ID to load and parse.
351 * @param string $block_id Block ID to search for.
352 * @return array The located block, or an empty array if not found/not viewable.
353 */
354 private function get_block_from_post( int $post_id, string $block_id ): array {
355 $post = get_post( $post_id );
356
357 if ( ! $post || empty( $post->post_content ) || ! $this->can_view_post( $post ) ) {
358 return array();
359 }
360
361 return $this->find_table_block( parse_blocks( $post->post_content ), $block_id );
362 }
363
364 /**
365 * Whether the current request is allowed to see a post's content.
366 * Prevents the [tableKit] shortcode from being used to pull table
367 * content out of private/draft/pending posts the viewer can't read.
368 *
369 * @param \WP_Post $post Post to check.
370 * @return bool True if the post is published, or the viewer has "read_post" capability on it.
371 */
372 private function can_view_post( \WP_Post $post ): bool {
373 if ( 'publish' === $post->post_status ) {
374 return true;
375 }
376
377 return current_user_can( 'read_post', $post->ID );
378 }
379
380 /**
381 * Searches the database for posts containing the given block ID.
382 * Candidate post IDs are cached for 5 minutes per block ID.
383 *
384 * @param string $block_id Block ID (or short ID) to search for in post content.
385 * @return array The located block, or an empty array if not found.
386 */
387 private function search_block_by_id( string $block_id ): array {
388 global $wpdb;
389
390 $cache_key = 'tablekit_block_search_' . md5( $block_id );
391 $candidate_ids = wp_cache_get( $cache_key, 'table-builder-block' );
392
393 if ( false === $candidate_ids ) {
394 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- no WP_Query equivalent for "find posts whose content contains this block ID"; result is cached just below.
395 $candidate_ids = $wpdb->get_col(
396 $wpdb->prepare(
397 "SELECT ID
398 FROM {$wpdb->posts}
399 WHERE post_content LIKE %s
400 AND post_status NOT IN ('trash', 'auto-draft')
401 LIMIT 10",
402 '%' . $wpdb->esc_like( $block_id ) . '%'
403 )
404 );
405
406 wp_cache_set( $cache_key, $candidate_ids, 'table-builder-block', 5 * MINUTE_IN_SECONDS );
407 }
408
409 foreach ( $candidate_ids as $id ) {
410 $block = $this->get_block_from_post( (int) $id, $block_id );
411 if ( $block ) {
412 return $block;
413 }
414 }
415
416 return array();
417 }
418
419 /**
420 * Recursively searches a block tree for a table block matching the given ID
421 * (by blockID or shortId attribute), or the first table block if no ID is given.
422 *
423 * @param array $blocks Block tree to search (parsed blocks, possibly with innerBlocks).
424 * @param string $block_id Block ID to match; empty string matches the first table block found.
425 * @return array The matched block, or an empty array if not found.
426 */
427 private function find_table_block( array $blocks, string $block_id ): array {
428 /**
429 * Filters the block names treated as "table blocks" by the [tableKit] shortcode,
430 * letting the Pro add-on (or third parties) register additional table block types.
431 *
432 * @since Unknown
433 *
434 * @param string[] $block_names Block names to match, e.g. "tablebuilder/table-builder".
435 */
436 // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores -- slash-namespaced hook name is this plugin's established public API (used by the Pro add-on); renaming would be a breaking change.
437 $block_names = apply_filters( 'tablekit/block_names', array( 'tablebuilder/table-builder' ) );
438
439 foreach ( $blocks as $block ) {
440 if ( in_array( $block['blockName'] ?? '', $block_names, true ) ) {
441 $id = (string) ( $block['attrs']['blockID'] ?? '' );
442 $short_id = (string) ( $block['attrs']['shortId'] ?? '' );
443
444 if ( ! $block_id || $id === $block_id || $short_id === $block_id ) {
445 return $block;
446 }
447 }
448
449 if ( ! empty( $block['innerBlocks'] ) ) {
450 $found = $this->find_table_block( $block['innerBlocks'], $block_id );
451 if ( $found ) {
452 return $found;
453 }
454 }
455 }
456
457 return array();
458 }
459
460 // -------------------------------------------------------------------------
461 // CSS Collection
462 // -------------------------------------------------------------------------
463
464 /**
465 * Collects combined CSS for every table block found within a block of post content.
466 *
467 * @param string $content Serialized block content (e.g. a post's post_content).
468 * @return string Combined CSS, or an empty string if there's no content/no table blocks.
469 */
470 public function collect_blocks_css_from_content( string $content ): string {
471 if ( '' === trim( $content ) ) {
472 return '';
473 }
474
475 $blocks = parse_blocks( $content );
476 if ( empty( $blocks ) ) {
477 return '';
478 }
479
480 /** This filter is documented in includes/Shortcode/Shortcode.php */
481 // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores -- slash-namespaced hook name is this plugin's established public API (used by the Pro add-on); renaming would be a breaking change.
482 $block_names = apply_filters( 'tablekit/block_names', array( 'tablebuilder/table-builder' ) );
483 $css = '';
484
485 foreach ( $blocks as $block ) {
486 $css .= $this->collect_css_from_block_tree( $block, $block_names );
487 }
488
489 return $css;
490 }
491
492 /**
493 * Recursively collects CSS from a block and its inner blocks, for any block
494 * whose name is in the given allow-list.
495 *
496 * @param array $block Parsed block array.
497 * @param array $block_names Block names to collect CSS for (e.g. "tablebuilder/table-builder").
498 * @return string Combined CSS from this block and its descendants.
499 */
500 private function collect_css_from_block_tree( array $block, array $block_names ): string {
501 $css = '';
502
503 if ( in_array( $block['blockName'] ?? '', $block_names, true ) ) {
504 $css .= ShortcodeUtils::collect_block_css( $block );
505 }
506
507 foreach ( $block['innerBlocks'] ?? array() as $inner_block ) {
508 $css .= $this->collect_css_from_block_tree( $inner_block, $block_names );
509 }
510
511 return $css;
512 }
513
514 // -------------------------------------------------------------------------
515 // Asset Management
516 // -------------------------------------------------------------------------
517
518 /**
519 * Enqueues the plugin's shared styles + the base table-builder block's assets,
520 * once per request.
521 *
522 * @return void
523 */
524 private function block_enqueue_assets(): void {
525 if ( self::$assets_enqueued ) {
526 return;
527 }
528
529 self::$assets_enqueued = true;
530 ShortcodeUtils::enqueue_shared_styles();
531 ShortcodeUtils::enqueue_block_assets( 'tablebuilder/table-builder' );
532 }
533
534 // -------------------------------------------------------------------------
535 // Public API
536 // -------------------------------------------------------------------------
537
538 /**
539 * Whether the shortcode's shared frontend assets have been enqueued yet this request.
540 *
541 * @return bool
542 */
543 public static function is_assets_enqueued(): bool {
544 return self::$assets_enqueued;
545 }
546
547 /**
548 * Sanitizes raw shortcode attributes.
549 *
550 * @param array $atts Raw attributes (already merged with defaults via shortcode_atts()).
551 * @return array Sanitized attributes.
552 */
553 private function sanitize_atts( array $atts ): array {
554 $atts['id'] = sanitize_text_field( (string) ( $atts['id'] ?? '' ) );
555 $atts['post_id'] = absint( $atts['post_id'] ?? 0 );
556 $atts['block_id'] = sanitize_text_field( (string) ( $atts['block_id'] ?? '' ) );
557 $atts['class'] = sanitize_text_field( (string) ( $atts['class'] ?? '' ) );
558 $atts['attrs_json'] = (string) ( $atts['attrs_json'] ?? '' );
559
560 return $atts;
561 }
562 }
563