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
← All changes | includes/Hooks/AssetGenerator.php +976 -357 2.0.1 → 2.3.0 View file →
@@ -1,357 +1,976 @@
1 -<?php
2 -namespace TableBuilder\Hooks;
3 -
4 -defined('ABSPATH') || exit;
5 -
6 -class AssetGenerator {
7 -
8 - use \TableBuilder\Traits\Singleton;
9 -
10 - /**
11 - * Defining css
12 - */
13 - public $css = '';
14 -
15 - /**
16 - * Defining fonts
17 - */
18 - protected $fonts = array();
19 -
20 - /**
21 - * AssetGenerator class constructor.
22 - * private for singleton
23 - *
24 - * @return void
25 - * @since 1.0.0
26 - */
27 - public function __construct() {
28 - add_action('save_post', array($this, 'save_post_hook'), 10, 3);
29 - add_filter('render_block_data', array($this, 'set_blocks_css'), 10);
30 - add_filter('wp_resource_hints', array($this, 'fonts_resource_hints'), 10, 2);
31 - add_action('wp_enqueue_scripts', array($this, 'enqueue_scripts'), 10);
32 - add_action('enqueue_block_assets', array($this, 'block_assets'), 10);
33 - }
34 -
35 - /**
36 - * Filters an array of blocks and returns only those where the block name contains 'tablebuilder'.
37 - *
38 - * @param array $blocks An array of blocks. Each block is an associative array that must contain a 'blockName' key. Default is an empty array.
39 - * @return array Returns an array of blocks where the block name contains 'tablebuilder'.
40 - */
41 - public function filter_blocks($blocks = array()) {
42 - $filtered_blocks = [];
43 -
44 - foreach ($blocks as $block) {
45 - if (isset($block['blockName']) && strpos($block['blockName'], 'tablebuilder') !== false) {
46 - $filtered_blocks[] = $block;
47 - }
48 -
49 - if (!empty($block['innerBlocks'])) {
50 - $filtered_blocks = array_merge($filtered_blocks, $this->filter_blocks($block['innerBlocks']));
51 - }
52 - }
53 -
54 - return $filtered_blocks;
55 - }
56 -
57 - /**
58 - * Minify CSS by condensing white spaces and removing comments.
59 - *
60 - * @param string $css The input CSS.
61 - * @return string Minified CSS.
62 - */
63 - public function minimize_css($css) {
64 - if (trim($css) === '') {
65 - return $css;
66 - }
67 -
68 - return preg_replace(
69 - array(
70 - '#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')|\/\*(?!\!)(?>.*?\*\/)|^\s*|\s*$#s',
71 - '#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\'|\/\*(?>.*?\*\/))|\s*+;\s*+(})\s*+|\s*+([*$~^|]?+=|[{};,>~]|\s(?![0-9\.])|!important\b)\s*+|([[(:])\s++|\s++([])])|\s++(:)\s*+(?!(?>[^{}"\']++|"(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')*+{)|^\s++|\s++\z|(\s)\s+#si',
72 - '#(?<=[\s:])(0)(cm|em|ex|in|mm|pc|pt|px|vh|vw|%)#si',
73 - '#:(0\s+0|0\s+0\s+0\s+0)(?=[;\}]|\!important)#i',
74 - '#(background-position):0(?=[;\}])#si',
75 - '#(?<=[\s:,\-])0+\.(\d+)#s',
76 - '#(\/\*(?>.*?\*\/))|(?<!content\:)([\'"])([a-z_][a-z0-9\-_]*?)\2(?=[\s\{\}\];,])#si',
77 - '#(\/\*(?>.*?\*\/))|(\burl\()([\'"])([^\s]+?)\3(\))#si',
78 - '#(?<=[\{;])(border|outline):none(?=[;\}\!])#',
79 - '#(\/\*(?>.*?\*\/))|(^|[\{\}])(?:[^\s\{\}]+)\{\}#s',
80 - ),
81 - array(
82 - '$1',
83 - '$1$2$3$4$5$6$7',
84 - '$1',
85 - ':0',
86 - '$1:0 0',
87 - '.$1',
88 - '$1$3',
89 - '$1$2$4$5',
90 - '$1$2$3',
91 - '$1:0',
92 - '$1$2',
93 - ),
94 - $css
95 - );
96 - }
97 -
98 - /**
99 - * Fires once a post has been saved.
100 - *
101 - * @param int $post_id The ID of the post.
102 - * @param WP_Post $post The post object.
103 - * @param bool $update Whether this is an existing post being updated.
104 - * @return void
105 - */
106 - public function save_post_hook($post_id, $post, $update) {
107 - // Bail out if is draft, revision, or autosave
108 - if ('auto-draft' === $post->post_status || wp_is_post_revision($post_id) || defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
109 - return;
110 - }
111 -
112 - // Skip if this is an autosave or update
113 - if (!$update) {
114 - return;
115 - }
116 -
117 - $post = get_post($post_id);
118 - $parse_blocks = $this->filter_blocks(parse_blocks($post->post_content));
119 -
120 - if ($parse_blocks) {
121 - $fse = in_array($post->post_type, ['wp_template_part', 'wp_template']);
122 - if ($fse) {
123 - $this->set_fonts(null, $this->generate_fse_assets(), true);
124 - } else {
125 - $this->set_fonts($post_id, $parse_blocks);
126 - }
127 - }
128 - }
129 -
130 - /**
131 - * Generate assets for templates.
132 - *
133 - * @return array $filtered_blocks The filtered blocks for FSE templates.
134 - */
135 - protected function generate_fse_assets() {
136 - $args = [
137 - 'post_type' => ['wp_template_part', 'wp_template'],
138 - 'posts_per_page' => -1,
139 - ];
140 -
141 - $posts = get_posts($args);
142 - $merged_blocks = [];
143 -
144 - foreach ($posts as $post) {
145 - $merged_blocks = array_merge($merged_blocks, parse_blocks($post->post_content));
146 - }
147 -
148 - return $this->filter_blocks($merged_blocks);
149 - }
150 -
151 - /**
152 - * Set the fonts for a given post or Full Site Editing (FSE) template.
153 - *
154 - * @param int $post_id The ID of the post or FSE template.
155 - * @param array $blocks An array of blocks.
156 - * @param bool $fse Whether this is an FSE template.
157 - * @return void
158 - */
159 - protected function set_fonts($post_id, $blocks, $fse = false) {
160 - $fonts = [];
161 -
162 - foreach ($blocks as $block) {
163 - if (isset($block['attrs'])) {
164 - $typographies = array_filter($block['attrs'], function ($key) {
165 - return str_contains(strtolower($key), 'typography');
166 - }, ARRAY_FILTER_USE_KEY);
167 -
168 - if (!empty($typographies)) {
169 - foreach ($typographies as $typography) {
170 - $font_weight = !empty($typography['fontWeight']['value']) ? $typography['fontWeight']['value'] : 400;
171 - if (!empty($typography['fontFamily']['value'])) {
172 - $fonts[$typography['fontFamily']['value']][] = $font_weight;
173 - }
174 - }
175 - }
176 - }
177 - }
178 -
179 - // Update fonts
180 - if (!empty($fonts)) {
181 - if ($fse) {
182 - update_option('table_builder_fse_fonts', $fonts);
183 - } else {
184 - update_post_meta($post_id, 'table_builder_posts_fonts', $fonts);
185 - }
186 - } else {
187 - if ($fse) {
188 - delete_option('table_builder_fse_fonts');
189 - } else {
190 - delete_post_meta($post_id, 'table_builder_posts_fonts');
191 - }
192 - }
193 - }
194 -
195 - /**
196 - * Combine block assets (CSS and JS) based on the used blocks.
197 - *
198 - * @param array $parsed_block The parsed block.
199 - * @return string Combined CSS content.
200 - */
201 - protected function combine_blocks_assets($parsed_block = []) {
202 - $blocks_css = [];
203 -
204 - if (isset($parsed_block['blockName']) && strpos($parsed_block['blockName'], 'tablebuilder') !== false) {
205 - if (isset($parsed_block['attrs']['blocksCSS'])) {
206 - foreach ($parsed_block['attrs']['blocksCSS'] as $device => $css) {
207 - if (!isset($blocks_css[$device])) {
208 - $blocks_css[$device] = '';
209 - }
210 - $blocks_css[$device] .= $css;
211 - }
212 - }
213 -
214 - if (isset($parsed_block['attrs'])) {
215 - $typographies = array_filter($parsed_block['attrs'], function ($key) {
216 - return str_contains(strtolower($key), 'typography');
217 - }, ARRAY_FILTER_USE_KEY);
218 -
219 - if (!empty($typographies)) {
220 - foreach ($typographies as $typography) {
221 - $font_weight = !empty($typography['fontWeight']['value']) ? $typography['fontWeight']['value'] : 400;
222 - if (!empty($typography['fontFamily']['value'])) {
223 - $this->fonts[$typography['fontFamily']['value']][] = $font_weight;
224 - }
225 - }
226 - }
227 - }
228 - }
229 -
230 - // Concatenate CSS content into a single string
231 - $css_content = '';
232 - $is_custom_styles_added = false;
233 - $device_list = \TableBuilder\Helpers\Utils::get_device_list();
234 -
235 - if (!empty($blocks_css)) {
236 - foreach ($device_list as $device) {
237 - foreach ($blocks_css as $key => $block) {
238 - if (!empty($block) && trim($block) !== '') {
239 - $direction = $device['direction'] ?? 'max';
240 - $width = $device['value'] ?? '';
241 - $device_key = strtolower($device['slug'] ?? '');
242 -
243 - if ('base' === $device['value'] && 'desktop' === $key) {
244 - $css_content .= $block;
245 - } elseif (!empty($direction) && !empty($width) && $device_key === $key) {
246 - $css_content .= "@media ({$direction}-width: {$width}px) {" . trim($block) . '}';
247 - }
248 -
249 - if ('customStyles' === $key && !$is_custom_styles_added) {
250 - $is_custom_styles_added = true;
251 - $css_content .= $block;
252 - }
253 - }
254 - }
255 - }
256 - }
257 -
258 - return $css_content;
259 - }
260 -
261 - /**
262 - * Generate Google Fonts URL.
263 - *
264 - * @return string|bool Google Fonts URL or false if no fonts.
265 - */
266 - protected function generate_fonts_url() {
267 - if (!empty($this->fonts)) {
268 - $font_families = [];
269 - $font_url = 'https://fonts.googleapis.com/css2?family=';
270 -
271 - // Remove duplicates and sort the fonts
272 - $all_fonts = array_map(function ($arr) {
273 - $arr = array_unique($arr);
274 - sort($arr);
275 - return $arr;
276 - }, $this->fonts);
277 -
278 - foreach ($all_fonts as $font => $weights) {
279 - $weights = array_map(function ($weight) {
280 - $invalid_list = ['normal', 'inherit', 'initial'];
281 - return in_array($weight, $invalid_list) ? '400' : $weight;
282 - }, $weights);
283 - sort($weights);
284 - $font_families[] = str_replace(' ', '+', $font) . ':wght@' . implode(';', array_unique($weights));
285 - }
286 -
287 - $font_url .= implode('&family=', $font_families);
288 - $font_url .= '&display=swap';
289 -
290 - return $font_url;
291 - }
292 -
293 - return false;
294 - }
295 -
296 - /**
297 - * Sets the CSS for the blocks.
298 - *
299 - * @param array $parsed_block The parsed block data.
300 - * @return array The modified parsed block data.
301 - */
302 - public function set_blocks_css($parsed_block) {
303 - $css_content = $this->combine_blocks_assets($parsed_block);
304 - if (!empty($css_content)) {
305 - $this->css .= $css_content;
306 - }
307 - return $parsed_block;
308 - }
309 -
310 - /**
311 - * Add preconnect for Google Fonts.
312 - *
313 - * @param array $urls URLs to print for resource hints.
314 - * @param string $relation_type The relation type the URLs are printed.
315 - * @return array
316 - */
317 - public function fonts_resource_hints($urls, $relation_type) {
318 - if (wp_style_is('table-builder-google-fonts', 'queue') && 'preconnect' === $relation_type) {
319 - $urls[] = [
320 - 'href' => 'https://fonts.gstatic.com',
321 - 'crossorigin',
322 - ];
323 - }
324 -
325 - return $urls;
326 - }
327 -
328 - /**
329 - * Enqueues the Google Fonts stylesheet if available.
330 - * Enqueues inline styles for the TableBuilder frontend.
331 - */
332 - public function enqueue_scripts() {
333 - global $post;
334 -
335 - if (!wp_is_block_theme() && !empty($post->post_content)) {
336 - do_blocks($post->post_content);
337 - }
338 -
339 - $fonts_url = $this->generate_fonts_url();
340 - if ($fonts_url) {
341 - wp_enqueue_style('table-builder-google-fonts', $fonts_url, false, null);
342 - }
343 -
344 - if ($this->css) {
345 - wp_add_inline_style('table-builder-style', $this->css);
346 - }
347 - }
348 -
349 - /**
350 - * Enqueues block assets (CSS & JS).
351 - *
352 - * @return void
353 - */
354 - public function block_assets() {
355 - wp_enqueue_style('table-builder-style', get_stylesheet_uri());
356 - }
357 -}
1 +<?php
2 +/**
3 + * Generates and enqueues per-post/per-block CSS and font assets
4 + *
5 + * @package TableKit
6 + */
7 +
8 +namespace TableBuilder\Hooks;
9 +
10 +defined( 'ABSPATH' ) || exit;
11 +
12 +/**
13 + * Collects block CSS/typography into inline styles and Google Fonts URLs,
14 + * and persists shortId attributes into saved block content.
15 + */
16 +class AssetGenerator {
17 +
18 +
19 + use \TableBuilder\Traits\Singleton;
20 +
21 + /**
22 + * Accumulated inline CSS for the current request.
23 + *
24 + * @var string
25 + */
26 + public $css = '';
27 +
28 + /**
29 + * Font-family => font-weight[] map collected from block typography attributes.
30 + *
31 + * @var array
32 + */
33 + protected $fonts = array();
34 +
35 + /**
36 + * Handle used for the theme-stylesheet backward-compat fallback (frontend only).
37 + *
38 + * @var string
39 + */
40 + const THEME_STYLESHEET_FALLBACK_HANDLE = 'tablekit-theme-stylesheet-fallback';
41 +
42 + /**
43 + * Max nesting depth when following synced patterns / registered patterns.
44 + *
45 + * @var int
46 + */
47 + const MAX_PATTERN_DEPTH = 10;
48 +
49 + /**
50 + * Keys of table blocks whose CSS has already been collected/output this request.
51 + *
52 + * @var array<string,bool>
53 + */
54 + protected $emitted_blocks = array();
55 +
56 + /**
57 + * CSS of table blocks rendered after the <head> CSS was finalised, keyed by
58 + * block key, waiting to be printed right before that block's markup.
59 + *
60 + * @var array<string,string>
61 + */
62 + protected $pending_late_css = array();
63 +
64 + /**
65 + * Whether the collected CSS has already been handed to WordPress for <head>.
66 + *
67 + * @var bool
68 + */
69 + protected $head_css_added = false;
70 +
71 + /**
72 + * Nesting counter for renders that print their own CSS (e.g. [tableKit]
73 + * shortcode, Elementor widget). While > 0, late CSS is not printed.
74 + *
75 + * @var int
76 + */
77 + protected $late_css_suspended = 0;
78 +
79 + /**
80 + * AssetGenerator class constructor.
81 + * private for singleton
82 + *
83 + * @return void
84 + * @since 1.0.0
85 + */
86 + public function __construct() {
87 + add_action( 'save_post', array( $this, 'save_post_hook' ), 10, 3 );
88 + add_filter( 'wp_insert_post_data', array( $this, 'persist_short_ids_in_content' ), 10, 2 );
89 + add_filter( 'render_block_data', array( $this, 'set_blocks_css' ), 10 );
90 + add_filter( 'wp_resource_hints', array( $this, 'fonts_resource_hints' ), 10, 2 );
91 + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ), 10 );
92 + add_action( 'enqueue_block_assets', array( $this, 'block_assets' ), 10 );
93 + add_action( 'wp_enqueue_scripts', array( $this, 'maybe_dequeue_theme_stylesheet_fallback' ), 999 );
94 + add_filter( 'render_block', array( $this, 'prepend_late_block_css' ), 1000, 2 );
95 +
96 + // Clear FSE cache when templates are saved.
97 + add_action( 'save_post_wp_template', array( $this, 'clear_fse_cache' ) );
98 + add_action( 'save_post_wp_template_part', array( $this, 'clear_fse_cache' ) );
99 + }
100 +
101 + /**
102 + * Clears the cached FSE (Full Site Editing) template block scan, hooked to
103 + * saving a wp_template/wp_template_part post.
104 + *
105 + * @return void
106 + */
107 + public function clear_fse_cache() {
108 + delete_transient( 'table_builder_fse_blocks' );
109 + }
110 +
111 + /**
112 + * Filters an array of blocks and returns only those where the block name contains 'tablebuilder'.
113 + *
114 + * @param array $blocks An array of blocks. Each block is an associative array that must contain a 'blockName' key. Default is an empty array.
115 + * @return array Returns an array of blocks where the block name contains 'tablebuilder'.
116 + */
117 + public function filter_blocks( $blocks = array() ) {
118 + $filtered_blocks = array();
119 +
120 + foreach ( $blocks as $block ) {
121 + if ( isset( $block['blockName'] ) && false !== strpos( $block['blockName'], 'tablebuilder' ) ) {
122 + $filtered_blocks[] = $block;
123 + }
124 +
125 + if ( ! empty( $block['innerBlocks'] ) ) {
126 + $filtered_blocks = array_merge( $filtered_blocks, $this->filter_blocks( $block['innerBlocks'] ) );
127 + }
128 + }
129 +
130 + return $filtered_blocks;
131 + }
132 +
133 + /**
134 + * Minifies CSS by condensing white spaces and removing comments.
135 + *
136 + * @param string $css The input CSS.
137 + * @return string Minified CSS.
138 + */
139 + public function minimize_css( $css ) {
140 + if ( '' === trim( $css ) ) {
141 + return $css;
142 + }
143 +
144 + return preg_replace(
145 + array(
146 + '#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')|\/\*(?!\!)(?>.*?\*\/)|^\s*|\s*$#s',
147 + '#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\'|\/\*(?>.*?\*\/))|\s*+;\s*+(})\s*+|\s*+([*$~^|]?+=|[{};,>~]|\s(?![0-9\.])|!important\b)\s*+|([[(:])\s++|\s++([])])|\s++(:)\s*+(?!(?>[^{}"\']++|"(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')*+{)|^\s++|\s++\z|(\s)\s+#si',
148 + '#(?<=[\s:])(0)(cm|em|ex|in|mm|pc|pt|px|vh|vw|%)#si',
149 + '#:(0\s+0|0\s+0\s+0\s+0)(?=[;\}]|\!important)#i',
150 + '#(background-position):0(?=[;\}])#si',
151 + '#(?<=[\s:,\-])0+\.(\d+)#s',
152 + '#(\/\*(?>.*?\*\/))|(?<!content\:)([\'"])([a-z_][a-z0-9\-_]*?)\2(?=[\s\{\}\];,])#si',
153 + '#(\/\*(?>.*?\*\/))|(\burl\()([\'"])([^\s]+?)\3(\))#si',
154 + '#(?<=[\{;])(border|outline):none(?=[;\}\!])#',
155 + '#(\/\*(?>.*?\*\/))|(^|[\{\}])(?:[^\s\{\}]+)\{\}#s',
156 + ),
157 + array(
158 + '$1',
159 + '$1$2$3$4$5$6$7',
160 + '$1',
161 + ':0',
162 + '$1:0 0',
163 + '.$1',
164 + '$1$3',
165 + '$1$2$4$5',
166 + '$1$2$3',
167 + '$1:0',
168 + '$1$2',
169 + ),
170 + $css
171 + );
172 + }
173 +
174 + /**
175 + * Fires once a post has been saved.
176 + *
177 + * @param int $post_id The ID of the post.
178 + * @param WP_Post $post The post object.
179 + * @param bool $update Whether this is an existing post being updated.
180 + * @return void
181 + */
182 + public function save_post_hook( $post_id, $post, $update ) {
183 + // Bail out if is draft, revision, or autosave.
184 + if ( 'auto-draft' === $post->post_status || wp_is_post_revision( $post_id ) || ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) ) {
185 + return;
186 + }
187 +
188 + // Skip if this is not an update (new post).
189 + if ( ! $update ) {
190 + return;
191 + }
192 +
193 + // Early bailout: Check if post content has tablebuilder blocks before parsing.
194 + if ( false === strpos( $post->post_content, 'tablebuilder' ) ) {
195 + return;
196 + }
197 +
198 + $post = get_post( $post_id );
199 + $parse_blocks = $this->filter_blocks( parse_blocks( $post->post_content ) );
200 +
201 + if ( $parse_blocks ) {
202 + $fse = in_array( $post->post_type, array( 'wp_template_part', 'wp_template' ), true );
203 + if ( $fse ) {
204 + $this->set_fonts( null, $this->generate_fse_assets(), true );
205 + } else {
206 + $this->set_fonts( $post_id, $parse_blocks );
207 + }
208 + }
209 + }
210 +
211 + /**
212 + * Persists shortId into saved block content before WordPress writes the post.
213 + *
214 + * @param array $data Sanitized post data about to be inserted/updated.
215 + * @param array $postarr Raw post data array as passed to wp_insert_post()/wp_update_post().
216 + * @return array Modified post data.
217 + */
218 + public function persist_short_ids_in_content( array $data, array $postarr ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- required by the "wp_insert_post_data" filter signature; not needed in the body.
219 + if ( empty( $data['post_content'] ) || false === strpos( (string) $data['post_content'], 'tablebuilder/' ) ) {
220 + return $data;
221 + }
222 +
223 + $content = wp_unslash( (string) $data['post_content'] );
224 + $blocks = parse_blocks( $content );
225 +
226 + if ( empty( $blocks ) ) {
227 + return $data;
228 + }
229 +
230 + $updated_blocks = $this->ensure_short_ids_for_blocks( $blocks );
231 + $updated_content = serialize_blocks( $updated_blocks );
232 +
233 + if ( '' === $updated_content || $updated_content === $content ) {
234 + return $data;
235 + }
236 +
237 + $data['post_content'] = wp_slash( $updated_content );
238 +
239 + return $data;
240 + }
241 +
242 + /**
243 + * Recursively walks blocks and populates missing shortId values for table blocks.
244 + *
245 + * @param array $blocks Parsed block tree to update in place.
246 + * @return array Block tree with shortId attributes filled in where missing.
247 + */
248 + protected function ensure_short_ids_for_blocks( array $blocks ): array {
249 + $target_blocks = array( 'tablebuilder/table-builder', 'tablebuilder/data-table', 'tablebuilder/post-table' );
250 +
251 + foreach ( $blocks as $index => $block ) {
252 + if ( ! empty( $block['innerBlocks'] ) ) {
253 + $block['innerBlocks'] = $this->ensure_short_ids_for_blocks( $block['innerBlocks'] );
254 + }
255 +
256 + if ( in_array( $block['blockName'] ?? '', $target_blocks, true ) ) {
257 + $attrs = $block['attrs'] ?? array();
258 +
259 + if ( empty( $attrs['shortId'] ) ) {
260 + $block_id = (string) ( $attrs['blockID'] ?? '' );
261 +
262 + if ( '' !== $block_id ) {
263 + $attrs['shortId'] = $this->generate_short_id( $block_id );
264 + $block['attrs'] = $attrs;
265 + }
266 + }
267 + }
268 +
269 + $blocks[ $index ] = $block;
270 + }
271 +
272 + return $blocks;
273 + }
274 +
275 + /**
276 + * Generate the same 6-digit shortId value used by the editor and list table.
277 + *
278 + * @param string $block_id Block's own ID (blockID attribute) to derive the hash from.
279 + * @return string 6-digit zero-padded numeric ID.
280 + */
281 + protected function generate_short_id( string $block_id ): string {
282 + $hash = 0;
283 +
284 + foreach ( str_split( $block_id ) as $character ) {
285 + $hash = ( ( $hash * 31 ) + ord( $character ) ) & 0xffffffff;
286 +
287 + if ( $hash > 0x7fffffff ) {
288 + $hash -= 0x100000000;
289 + }
290 + }
291 +
292 + return str_pad( (string) ( abs( $hash ) % 1000000 ), 6, '0', STR_PAD_LEFT );
293 + }
294 +
295 + /**
296 + * Generate assets for templates.
297 + * Now with caching to improve performance.
298 + *
299 + * @return array $filtered_blocks The filtered blocks for FSE templates.
300 + */
301 + protected function generate_fse_assets() {
302 + // Check cache first (expires after 6 hours).
303 + $cached_blocks = get_transient( 'table_builder_fse_blocks' );
304 + if ( false !== $cached_blocks && is_array( $cached_blocks ) ) {
305 + return $cached_blocks;
306 + }
307 +
308 + $args = array(
309 + 'post_type' => array( 'wp_template_part', 'wp_template' ),
310 + 'posts_per_page' => 100, // Limit to 100 templates for performance.
311 + 'orderby' => 'modified',
312 + 'order' => 'DESC',
313 + 'no_found_rows' => true, // Improve query performance.
314 + 'update_post_meta_cache' => false, // Skip meta cache.
315 + 'update_post_term_cache' => false, // Skip term cache.
316 + );
317 +
318 + $posts = get_posts( $args );
319 + $merged_blocks = array();
320 +
321 + foreach ( $posts as $post ) {
322 + $merged_blocks = array_merge( $merged_blocks, parse_blocks( $post->post_content ) );
323 + }
324 +
325 + $filtered_blocks = $this->filter_blocks( $merged_blocks );
326 +
327 + // Cache for 6 hours.
328 + set_transient( 'table_builder_fse_blocks', $filtered_blocks, 6 * HOUR_IN_SECONDS );
329 +
330 + return $filtered_blocks;
331 + }
332 +
333 + /**
334 + * Sets the fonts for a given post or Full Site Editing (FSE) template.
335 + *
336 + * @param int $post_id The ID of the post or FSE template.
337 + * @param array $blocks An array of blocks.
338 + * @param bool $fse Whether this is an FSE template.
339 + * @return void
340 + */
341 + protected function set_fonts( $post_id, $blocks, $fse = false ) {
342 + $fonts = array();
343 +
344 + foreach ( $blocks as $block ) {
345 + if ( isset( $block['attrs'] ) ) {
346 + $typographies = array_filter(
347 + $block['attrs'],
348 + function ( $key ) {
349 + return str_contains( strtolower( $key ), 'typography' );
350 + },
351 + ARRAY_FILTER_USE_KEY
352 + );
353 +
354 + if ( ! empty( $typographies ) ) {
355 + foreach ( $typographies as $typography ) {
356 + $font_weight = ! empty( $typography['fontWeight']['value'] ) ? $typography['fontWeight']['value'] : 400;
357 + if ( ! empty( $typography['fontFamily']['value'] ) ) {
358 + $fonts[ $typography['fontFamily']['value'] ][] = $font_weight;
359 + }
360 + }
361 + }
362 + }
363 + }
364 +
365 + // Update fonts.
366 + if ( ! empty( $fonts ) ) {
367 + if ( $fse ) {
368 + update_option( 'table_builder_fse_fonts', $fonts );
369 + } else {
370 + update_post_meta( $post_id, 'table_builder_posts_fonts', $fonts );
371 + }
372 + } elseif ( $fse ) {
373 + delete_option( 'table_builder_fse_fonts' );
374 + } else {
375 + delete_post_meta( $post_id, 'table_builder_posts_fonts' );
376 + }
377 + }
378 +
379 + /**
380 + * Combines block assets (CSS and JS) based on the used blocks.
381 + *
382 + * @param array $parsed_block The parsed block.
383 + * @return string Combined CSS content.
384 + */
385 + protected function combine_blocks_assets( $parsed_block = array() ) {
386 + $blocks_css = array();
387 +
388 + if ( isset( $parsed_block['blockName'] ) && false !== strpos( $parsed_block['blockName'], 'tablebuilder' ) ) {
389 + if ( isset( $parsed_block['attrs']['blocksCSS'] ) ) {
390 + foreach ( $parsed_block['attrs']['blocksCSS'] as $device => $css ) {
391 + if ( ! isset( $blocks_css[ $device ] ) ) {
392 + $blocks_css[ $device ] = '';
393 + }
394 +
395 + if ( is_string( $css ) ) {
396 + $blocks_css[ $device ] .= preg_replace( '/<[^>]*>?/', '', $css );
397 + }
398 + }
399 + }
400 +
401 + // block typography.
402 + $this->set_typography( $parsed_block );
403 +
404 + // block common style.
405 + if ( isset( $parsed_block['attrs']['commonStyle'] ) ) {
406 + foreach ( $parsed_block['attrs']['commonStyle'] as $device => $css ) {
407 + if ( ! isset( $blocks_css[ $device ] ) ) {
408 + $blocks_css[ $device ] = '';
409 + }
410 +
411 + if ( is_string( $css ) ) {
412 + $blocks_css[ $device ] .= preg_replace( '/<[^>]*>?/', '', $css );
413 + }
414 + }
415 + }
416 + }
417 +
418 + // Concatenate CSS content into a single string.
419 + $css_content = '';
420 + $is_custom_styles_added = false;
421 + $device_list = \TableBuilder\Helpers\Utils::get_device_list();
422 +
423 + if ( ! empty( $blocks_css ) ) {
424 + foreach ( $device_list as $device ) {
425 + foreach ( $blocks_css as $key => $block ) {
426 + if ( ! empty( $block ) && '' !== trim( $block ) ) {
427 + $direction = $device['direction'] ?? 'max';
428 + $width = $device['value'] ?? '';
429 + $device_key = strtolower( $device['slug'] ?? '' );
430 +
431 + if ( 'base' === $device['value'] && 'desktop' === $key ) {
432 + $css_content .= $block;
433 + } elseif ( ! empty( $direction ) && ! empty( $width ) && $device_key === $key ) {
434 + $css_content .= "@media ({$direction}-width: {$width}px) {" . trim( $block ) . '}';
435 + }
436 +
437 + if ( 'customStyles' === $key && ! $is_custom_styles_added ) {
438 + $is_custom_styles_added = true;
439 + $css_content .= $block;
440 + }
441 + }
442 + }
443 + }
444 + }
445 +
446 + return $css_content;
447 + }
448 +
449 + /**
450 + * Collects font-family/font-weight pairs from a block's typography-related
451 + * attributes into $this->fonts, for later Google Fonts URL generation.
452 + *
453 + * @param array $parsed_block The parsed block data.
454 + * @return void
455 + */
456 + protected function set_typography( $parsed_block ) {
457 + if ( isset( $parsed_block['attrs'] ) ) {
458 + $typographies = array_filter(
459 + $parsed_block['attrs'],
460 + function ( $key ) {
461 + $key = strtolower( $key );
462 + return str_contains( $key, 'typography' ) || str_contains( $key, 'typo' );
463 + },
464 + ARRAY_FILTER_USE_KEY
465 + );
466 +
467 + if ( ! empty( $typographies ) ) {
468 + foreach ( $typographies as $typography ) {
469 + $font_weight = ! empty( $typography['fontWeight']['value'] ) ? $typography['fontWeight']['value'] : 400;
470 + if ( ! empty( $typography['fontFamily']['value'] ) ) {
471 + $this->fonts[ $typography['fontFamily']['value'] ][] = $font_weight;
472 + }
473 + }
474 + }
475 + }
476 + }
477 +
478 + /**
479 + * Generate Google Fonts URL.
480 + *
481 + * @return string|bool Google Fonts URL or false if no fonts.
482 + */
483 + protected function generate_fonts_url() {
484 + if ( ! empty( $this->fonts ) ) {
485 + $font_families = array();
486 + $font_url = 'https://fonts.googleapis.com/css2?family=';
487 +
488 + // Remove duplicates and sort the fonts.
489 + $all_fonts = array_map(
490 + function ( $arr ) {
491 + $arr = array_unique( $arr );
492 + sort( $arr );
493 + return $arr;
494 + },
495 + $this->fonts
496 + );
497 +
498 + foreach ( $all_fonts as $font => $weights ) {
499 + $weights = array_map(
500 + function ( $weight ) {
501 + $invalid_list = array( 'normal', 'inherit', 'initial' );
502 + return in_array( $weight, $invalid_list, true ) ? '400' : $weight;
503 + },
504 + $weights
505 + );
506 + sort( $weights );
507 + $font_families[] = str_replace( ' ', '+', $font ) . ':wght@' . implode( ';', array_unique( $weights ) );
508 + }
509 +
510 + $font_url .= implode( '&family=', $font_families );
511 + $font_url .= '&display=swap';
512 +
513 + return $font_url;
514 + }
515 +
516 + return false;
517 + }
518 +
519 + /**
520 + * Sets the CSS for the blocks.
521 + *
522 + * @param array $parsed_block The parsed block data.
523 + * @return array The modified parsed block data.
524 + */
525 + public function set_blocks_css( $parsed_block ) {
526 + if ( ! is_array( $parsed_block ) || ! $this->is_table_block( $parsed_block ) ) {
527 + return $parsed_block;
528 + }
529 +
530 + $key = $this->get_block_key( $parsed_block );
531 +
532 + if ( isset( $this->emitted_blocks[ $key ] ) ) {
533 + return $parsed_block;
534 + }
535 +
536 + // Before the <head> CSS is finalised (block themes render templates early,
537 + // classic themes are pre-collected in enqueue_scripts()): add to the inline CSS.
538 + if ( ! $this->head_css_added ) {
539 + $this->emitted_blocks[ $key ] = true;
540 +
541 + $css_content = $this->combine_blocks_assets( $parsed_block );
542 + if ( ! empty( $css_content ) ) {
543 + $this->css .= $css_content;
544 + }
545 +
546 + return $parsed_block;
547 + }
548 +
549 + // Rendered after <head> (widgets, archive loops, nested queries...): collect
550 + // this block's whole subtree once and print it before the block markup.
551 + $late_css = $this->collect_late_tree_css( $parsed_block );
552 +
553 + if ( '' !== $late_css && 0 === $this->late_css_suspended ) {
554 + $this->pending_late_css[ $key ] = $late_css;
555 + }
556 +
557 + return $parsed_block;
558 + }
559 +
560 + /**
561 + * Collects CSS for a table block and all its inner table blocks that haven't
562 + * been output yet, marking each one as output.
563 + *
564 + * @param array $parsed_block Parsed block.
565 + * @return string Combined CSS.
566 + */
567 + protected function collect_late_tree_css( array $parsed_block ): string {
568 + $css = '';
569 +
570 + if ( $this->is_table_block( $parsed_block ) ) {
571 + $key = $this->get_block_key( $parsed_block );
572 +
573 + if ( ! isset( $this->emitted_blocks[ $key ] ) ) {
574 + $this->emitted_blocks[ $key ] = true;
575 + $css .= $this->combine_blocks_assets( $parsed_block );
576 + }
577 + }
578 +
579 + foreach ( $parsed_block['innerBlocks'] ?? array() as $inner_block ) {
580 + if ( is_array( $inner_block ) ) {
581 + $css .= $this->collect_late_tree_css( $inner_block );
582 + }
583 + }
584 +
585 + return $css;
586 + }
587 +
588 + /**
589 + * Prints CSS collected for a late-rendered table block right before its markup.
590 + * Hooked to "render_block" at a late priority so other markup filters
591 + * (which target the block's first tag) run first.
592 + *
593 + * @param string $block_content Rendered block HTML.
594 + * @param array $parsed_block Parsed block data.
595 + * @return string
596 + */
597 + public function prepend_late_block_css( $block_content, $parsed_block ) {
598 + if ( empty( $this->pending_late_css ) || ! is_array( $parsed_block ) || ! $this->is_table_block( $parsed_block ) ) {
599 + return $block_content;
600 + }
601 +
602 + $key = $this->get_block_key( $parsed_block );
603 +
604 + if ( ! isset( $this->pending_late_css[ $key ] ) ) {
605 + return $block_content;
606 + }
607 +
608 + $css = $this->pending_late_css[ $key ];
609 + unset( $this->pending_late_css[ $key ] );
610 +
611 + return '<style id="tablekit-css-' . esc_attr( sanitize_html_class( $key ) ) . '">' . $css . '</style>' . $block_content;
612 + }
613 +
614 + /**
615 + * Suspends late CSS printing, for renderers that print their own CSS
616 + * (e.g. the [tableKit] shortcode or the Elementor widget). Must be paired
617 + * with resume_late_css().
618 + *
619 + * @return void
620 + */
621 + public function suspend_late_css(): void {
622 + ++$this->late_css_suspended;
623 + }
624 +
625 + /**
626 + * Resumes late CSS printing after suspend_late_css().
627 + *
628 + * @return void
629 + */
630 + public function resume_late_css(): void {
631 + $this->late_css_suspended = max( 0, $this->late_css_suspended - 1 );
632 + }
633 +
634 + /**
635 + * Whether a parsed block is one of TableKit's blocks.
636 + *
637 + * @param array $parsed_block Parsed block.
638 + * @return bool
639 + */
640 + protected function is_table_block( array $parsed_block ): bool {
641 + return isset( $parsed_block['blockName'] ) && is_string( $parsed_block['blockName'] ) && false !== strpos( $parsed_block['blockName'], 'tablebuilder' );
642 + }
643 +
644 + /**
645 + * Stable per-request key for a table block (its blockID, or a hash of its attributes).
646 + *
647 + * @param array $parsed_block Parsed block.
648 + * @return string
649 + */
650 + protected function get_block_key( array $parsed_block ): string {
651 + $block_id = $parsed_block['attrs']['blockID'] ?? '';
652 +
653 + if ( is_string( $block_id ) && '' !== $block_id ) {
654 + return $parsed_block['blockName'] . ':' . $block_id;
655 + }
656 +
657 + return $parsed_block['blockName'] . ':' . md5( (string) wp_json_encode( $parsed_block['attrs'] ?? array() ) );
658 + }
659 +
660 + /**
661 + * Collects TableKit CSS (and enqueues block assets) for the current post on
662 + * classic themes WITHOUT rendering any block.
663 + *
664 + * IMPORTANT: never render blocks here (no do_blocks, render_block or the_content). Rendering
665 + * third-party blocks early triggers their one-time side effects (e.g. Formidable
666 + * Forms prints and marks its stylesheet as loaded inside the discarded output,
667 + * so forms lose their styling) and processes form submissions twice.
668 + *
669 + * @param \WP_Post $post Post to collect CSS for.
670 + * @return void
671 + */
672 + protected function collect_post_css( \WP_Post $post ): void {
673 + $content = (string) $post->post_content;
674 +
675 + if ( '' === $content
676 + || ( false === strpos( $content, 'tablebuilder' )
677 + && false === strpos( $content, '<!-- wp:block ' )
678 + && false === strpos( $content, '<!-- wp:pattern ' ) )
679 + ) {
680 + return;
681 + }
682 +
683 + $seen = array();
684 + $this->collect_css_from_blocks( parse_blocks( $content ), $seen, 0 );
685 + }
686 +
687 + /**
688 + * Walks a parsed block tree (following synced patterns and registered
689 + * patterns) and collects CSS for every TableKit block, in document order.
690 + *
691 + * @param array $blocks Parsed blocks.
692 + * @param array $seen Pattern references already followed (loop guard).
693 + * @param int $depth Current pattern nesting depth.
694 + * @return void
695 + */
696 + protected function collect_css_from_blocks( array $blocks, array &$seen, int $depth ): void {
697 + foreach ( $blocks as $block ) {
698 + if ( ! is_array( $block ) ) {
699 + continue;
700 + }
701 +
702 + $name = $block['blockName'] ?? '';
703 +
704 + if ( $this->is_table_block( $block ) ) {
705 + $this->set_blocks_css( $block );
706 + $this->enqueue_table_block_assets( $name );
707 + }
708 +
709 + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
710 + $this->collect_css_from_blocks( $block['innerBlocks'], $seen, $depth );
711 + }
712 +
713 + if ( $depth >= self::MAX_PATTERN_DEPTH ) {
714 + continue;
715 + }
716 +
717 + $pattern_content = $this->get_pattern_content( $block, $seen );
718 +
719 + if ( '' !== $pattern_content ) {
720 + $this->collect_css_from_blocks( parse_blocks( $pattern_content ), $seen, $depth + 1 );
721 + }
722 + }
723 + }
724 +
725 + /**
726 + * Returns the content of a synced pattern (core/block) or registered pattern
727 + * (core/pattern) block, once per request.
728 + *
729 + * @param array $block Parsed block.
730 + * @param array $seen Pattern references already followed (loop guard).
731 + * @return string Pattern content, or an empty string.
732 + */
733 + protected function get_pattern_content( array $block, array &$seen ): string {
734 + $name = $block['blockName'] ?? '';
735 +
736 + if ( 'core/block' === $name && ! empty( $block['attrs']['ref'] ) ) {
737 + $ref = 'ref:' . absint( $block['attrs']['ref'] );
738 +
739 + if ( isset( $seen[ $ref ] ) ) {
740 + return '';
741 + }
742 + $seen[ $ref ] = true;
743 +
744 + $pattern = get_post( absint( $block['attrs']['ref'] ) );
745 +
746 + if ( $pattern instanceof \WP_Post && 'wp_block' === $pattern->post_type && 'publish' === $pattern->post_status ) {
747 + return (string) $pattern->post_content;
748 + }
749 +
750 + return '';
751 + }
752 +
753 + if ( 'core/pattern' === $name && ! empty( $block['attrs']['slug'] ) && class_exists( '\\WP_Block_Patterns_Registry' ) ) {
754 + $slug = 'slug:' . (string) $block['attrs']['slug'];
755 +
756 + if ( isset( $seen[ $slug ] ) ) {
757 + return '';
758 + }
759 + $seen[ $slug ] = true;
760 +
761 + $registered = \WP_Block_Patterns_Registry::get_instance()->get_registered( (string) $block['attrs']['slug'] );
762 +
763 + return is_array( $registered ) && ! empty( $registered['content'] ) ? (string) $registered['content'] : '';
764 + }
765 +
766 + return '';
767 + }
768 +
769 + /**
770 + * Enqueues a TableKit block's registered frontend style/view-script handles
771 + * up front, so they still print in <head> exactly as they did when the block
772 + * was rendered early (prevents a flash of unstyled table).
773 + *
774 + * @param string $block_name Block name, e.g. "tablebuilder/table-builder".
775 + * @return void
776 + */
777 + protected function enqueue_table_block_assets( string $block_name ): void {
778 + static $done = array();
779 +
780 + if ( isset( $done[ $block_name ] ) ) {
781 + return;
782 + }
783 + $done[ $block_name ] = true;
784 +
785 + $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( $block_name );
786 +
787 + if ( ! $block_type ) {
788 + return;
789 + }
790 +
791 + foreach ( (array) ( $block_type->style_handles ?? array() ) as $handle ) {
792 + if ( is_string( $handle ) && wp_style_is( $handle, 'registered' ) ) {
793 + wp_enqueue_style( $handle );
794 + }
795 + }
796 +
797 + foreach ( (array) ( $block_type->view_script_handles ?? array() ) as $handle ) {
798 + if ( is_string( $handle ) && wp_script_is( $handle, 'registered' ) ) {
799 + wp_enqueue_script( $handle );
800 + }
801 + }
802 + }
803 +
804 + /**
805 + * Adds a preconnect resource hint for Google Fonts.
806 + *
807 + * @param array $urls URLs to print for resource hints.
808 + * @param string $relation_type The relation type the URLs are printed.
809 + * @return array
810 + */
811 + public function fonts_resource_hints( $urls, $relation_type ) {
812 + if ( wp_style_is( 'table-builder-google-fonts', 'queue' ) && 'preconnect' === $relation_type ) {
813 + $urls[] = array(
814 + 'href' => 'https://fonts.gstatic.com',
815 + 'crossorigin',
816 + );
817 + }
818 +
819 + return $urls;
820 + }
821 +
822 + /**
823 + * Enqueues the Google Fonts stylesheet if available.
824 + * Enqueues inline styles for the TableBuilder frontend.
825 + *
826 + * @return void
827 + */
828 + public function enqueue_scripts() {
829 + global $post;
830 +
831 + // Classic themes render the post after <head>, so collect its TableKit CSS
832 + // now by reading block attributes only, never by rendering (see collect_post_css()).
833 + if ( ! wp_is_block_theme() && $post instanceof \WP_Post && ! empty( $post->post_content ) ) {
834 + $this->collect_post_css( $post );
835 + }
836 +
837 + $fonts_url = $this->generate_fonts_url();
838 + if ( $fonts_url ) {
839 + wp_enqueue_style( 'table-builder-google-fonts', $fonts_url, false, TABLE_BUILDER_BLOCK_PLUGIN_VERSION );
840 + }
841 +
842 + if ( $this->css ) {
843 + wp_add_inline_style( 'table-builder-style', $this->css );
844 + $this->print_inline_css_after_block_styles();
845 + }
846 +
847 + $this->head_css_added = true;
848 + }
849 +
850 + /**
851 + * Makes TableKit's per-block inline CSS print AFTER the TableKit block
852 + * stylesheets (style-index.css) that are enqueued on this page.
853 + *
854 + * The per-block CSS generated from block settings uses selectors with the
855 + * same specificity as the block's base stylesheet, so it only wins when it
856 + * comes later in the page. Classic themes (e.g. Kadence) enqueue things in
857 + * an order where the inline CSS was printed first, so user-chosen colours,
858 + * paddings etc. were silently overridden by the defaults.
859 + *
860 + * @return void
861 + */
862 + protected function print_inline_css_after_block_styles(): void {
863 + $styles = wp_styles();
864 +
865 + if ( empty( $styles->registered['table-builder-style'] ) ) {
866 + return;
867 + }
868 +
869 + $deps = array();
870 +
871 + foreach ( \WP_Block_Type_Registry::get_instance()->get_all_registered() as $block_name => $block_type ) {
872 + if ( false === strpos( (string) $block_name, 'tablebuilder' ) ) {
873 + continue;
874 + }
875 +
876 + foreach ( (array) ( $block_type->style_handles ?? array() ) as $handle ) {
877 + if ( is_string( $handle ) && 'table-builder-style' !== $handle && wp_style_is( $handle, 'enqueued' ) ) {
878 + $deps[] = $handle;
879 + }
880 + }
881 + }
882 +
883 + if ( empty( $deps ) ) {
884 + return;
885 + }
886 +
887 + $style = $styles->registered['table-builder-style'];
888 + $style->deps = array_values( array_unique( array_merge( (array) $style->deps, $deps ) ) );
889 + }
890 +
891 + /**
892 + * Enqueues block assets (CSS & JS).
893 + *
894 + * @return void
895 + */
896 + public function block_assets() {
897 + // Block editor: unchanged behaviour.
898 + if ( is_admin() ) {
899 + wp_enqueue_style( 'table-builder-style', get_stylesheet_uri(), array(), TABLE_BUILDER_BLOCK_PLUGIN_VERSION );
900 + return;
901 + }
902 +
903 + /**
904 + * Frontend backward-compat: some (child) themes never enqueue their own
905 + * style.css and may have relied on TableKit loading it. It is enqueued here
906 + * (same position as before) and dequeued again in
907 + * maybe_dequeue_theme_stylesheet_fallback() when the theme loads it itself,
908 + * so it is never printed twice.
909 + *
910 + * @since 2.2.15
911 + *
912 + * @param bool $load Whether to load the theme stylesheet as a fallback. Default true.
913 + */
914 + if ( apply_filters( 'tablekit_load_theme_stylesheet_fallback', true ) ) {
915 + wp_enqueue_style( self::THEME_STYLESHEET_FALLBACK_HANDLE, get_stylesheet_uri(), array(), TABLE_BUILDER_BLOCK_PLUGIN_VERSION );
916 + }
917 +
918 + // Source-less handle that only carries TableKit's inline CSS.
919 + wp_register_style( 'table-builder-style', false, array(), TABLE_BUILDER_BLOCK_PLUGIN_VERSION );
920 + wp_enqueue_style( 'table-builder-style' );
921 + }
922 +
923 + /**
924 + * Dequeues the theme-stylesheet fallback when the theme (or anything else)
925 + * already enqueues the same stylesheet, directly or as a dependency.
926 + *
927 + * @return void
928 + */
929 + public function maybe_dequeue_theme_stylesheet_fallback() {
930 + if ( is_admin() || ! wp_style_is( self::THEME_STYLESHEET_FALLBACK_HANDLE, 'enqueued' ) ) {
931 + return;
932 + }
933 +
934 + $target = $this->normalize_style_src( get_stylesheet_uri() );
935 + $styles = wp_styles();
936 + $stack = (array) $styles->queue;
937 + $seen = array();
938 +
939 + while ( ! empty( $stack ) ) {
940 + $handle = array_pop( $stack );
941 +
942 + if ( ! is_string( $handle ) || isset( $seen[ $handle ] ) || self::THEME_STYLESHEET_FALLBACK_HANDLE === $handle ) {
943 + continue;
944 + }
945 + $seen[ $handle ] = true;
946 +
947 + $style = $styles->registered[ $handle ] ?? null;
948 +
949 + if ( ! $style ) {
950 + continue;
951 + }
952 +
953 + if ( is_string( $style->src ) && '' !== $style->src && $this->normalize_style_src( $style->src ) === $target ) {
954 + wp_dequeue_style( self::THEME_STYLESHEET_FALLBACK_HANDLE );
955 + return;
956 + }
957 +
958 + foreach ( (array) $style->deps as $dep ) {
959 + $stack[] = $dep;
960 + }
961 + }
962 + }
963 +
964 + /**
965 + * Normalises a stylesheet URL for comparison (drops scheme, host and query).
966 + *
967 + * @param string $src Stylesheet URL (absolute, protocol-relative or root-relative).
968 + * @return string
969 + */
970 + protected function normalize_style_src( string $src ): string {
971 + $src = strtok( $src, '?#' );
972 + $src = preg_replace( '#^(?:https?:)?//[^/]+#i', '', (string) $src );
973 +
974 + return strtolower( untrailingslashit( (string) $src ) );
975 + }
976 +}