manager = $manager; $this->hooks = $hooks; } /** * Queues the render_block filter via the hook loader. * * @return void */ public function register(): void { $this->hooks->add_filter( 'render_block', array( $this, 'on_render' ), 10, 2 ); } /** * Filters a rendered block based on its visibility model. * * @param string $block_content Rendered block HTML. * @param mixed $block Parsed block, including its attributes (a public filter value). * @return string Original content when visible, empty string when hidden. */ public function on_render( $block_content, $block ) { /* * Core renders each Query Loop item as a `core/null` copy of the Post * Template block, attributes included. The Post Template's own model is * enforced once on the block itself, never per item. */ if ( is_array( $block ) && 'core/null' === ( $block['blockName'] ?? null ) ) { return $block_content; } $model = $this->extract_model( $block ); // No (or no active) visibility configured — render unchanged. if ( null === $model || empty( $model['enabled'] ) || empty( $model['conditions'] ) ) { return $block_content; } // Never hide or restyle content while it is being edited. if ( $this->is_editor_request() ) { return $block_content; } if ( ! $this->manager->should_render( $model ) ) { return ''; } $device_classes = $this->device_hide_classes( $model ); if ( '' === $device_classes || '' === trim( (string) $block_content ) ) { return $block_content; } /* * The responsive utility classes are useless without the stylesheet, and * the queried-post scan in AssetService cannot see blocks that live in * templates, template parts, patterns or widgets. Enqueue on demand so * device targeting works everywhere. */ $this->enqueue_device_styles(); return $this->add_device_classes( (string) $block_content, $device_classes ); } /** * Adds the responsive hide-classes to a block's markup. * * The classes go on the block's own root element so it keeps its place in * the parent layout (constrained widths, flow spacing, flex/grid rules all * target direct children). Markup without a single root element — e.g. a * Custom HTML block with several siblings — falls back to a wrapper made * layout-transparent by `display: contents`. * * @param string $html Rendered block HTML. * @param string $classes Space-separated hide classes. * @return string */ private function add_device_classes( string $html, string $classes ): string { $updated = $this->add_classes_to_single_root( $html, $classes ); if ( null !== $updated ) { return $updated; } return sprintf( '
%2$s
', esc_attr( self::DEVICE_WRAPPER_CLASS . ' ' . $classes ), $html ); } /** * Adds classes to the markup's root element when there is exactly one. * * Uses the HTML Processor so nesting is resolved like a browser would, and * adds the classes with that same processor while it sits on the root: tags * a browser ignores in body context (a pasted ``/``, a stray * ``) are never the root, so the classes can never end up merged onto * the page's own `` or dropped on an ignored tag. Any markup it cannot * fully parse is treated as "not a single root". * * @param string $html Rendered block HTML. * @param string $classes Space-separated classes. * @return string|null Updated HTML, or null when there is no single root. */ private function add_classes_to_single_root( string $html, string $classes ): ?string { $processor = \WP_HTML_Processor::create_fragment( $html ); if ( null === $processor ) { return null; } $root_depth = null; $roots = 0; while ( $processor->next_token() ) { $type = $processor->get_token_type(); $depth = $processor->get_current_depth(); if ( '#tag' === $type ) { if ( $processor->is_tag_closer() ) { continue; } $root_depth ??= $depth; if ( $depth === $root_depth ) { if ( ++$roots > 1 ) { return null; } foreach ( explode( ' ', $classes ) as $class ) { // E.g. a stray closing tag: cannot carry a class, use the wrapper. if ( ! $processor->add_class( $class ) ) { return null; } } } continue; } // Non-whitespace text outside the root element. if ( '#text' === $type && ( null === $root_depth || $depth <= $root_depth ) && '' !== trim( $processor->get_modifiable_text() ) ) { return null; } } if ( 1 !== $roots || null !== $processor->get_last_error() ) { return null; } return $processor->get_updated_html(); } /** * Reports whether the current request renders blocks for an editor. * * Covers admin screens (post editor, site editor, widgets screen) and the * REST `block-renderer` route used to preview server-rendered blocks. * * admin-ajax.php also reports is_admin(), but it serves front-end features * (load-more, modals, quick views) to visitors — including logged-out ones — * and the editor never renders blocks through it, so AJAX requests are * enforced like any other front-end request. * * @return bool */ private function is_editor_request(): bool { // admin-post.php is "admin" too, but its handlers serve visitors, not the editor. if ( is_admin() && ! wp_doing_ajax() && 'admin-post.php' !== ( $GLOBALS['pagenow'] ?? '' ) ) { return true; } if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) { return false; } return false !== strpos( $this->current_rest_route(), '/block-renderer/' ); } /** * Resolves the route of the REST request currently being served. * * @return string Route path, or an empty string when it cannot be resolved. */ private function current_rest_route(): string { $route = $GLOBALS['wp']->query_vars['rest_route'] ?? ''; if ( is_string( $route ) && '' !== $route ) { return $route; } // Fall back to the raw request path (plain-permalink REST requests). $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; return (string) wp_parse_url( $uri, PHP_URL_PATH ); } /** * Ensures the front-end stylesheet backing the device classes is loaded. * * @return void */ private function enqueue_device_styles(): void { if ( wp_style_is( AssetService::FRONT_STYLE_HANDLE, 'enqueued' ) ) { return; } $relative = 'assets/css/front.css'; if ( ! is_readable( BLOCKSIEVE_PATH . $relative ) ) { return; } wp_enqueue_style( AssetService::FRONT_STYLE_HANDLE, BLOCKSIEVE_URL . $relative, array(), BLOCKSIEVE_VERSION . '-' . (string) filemtime( BLOCKSIEVE_PATH . $relative ) ); } /** * Extracts the visibility model from a parsed block's attributes. * * Accepts `mixed` because `render_block` is a public filter: another plugin * may hand it something that is not a parsed-block array. * * @param mixed $block Parsed block. * @return array|null The model, or null when none is configured. */ private function extract_model( $block ): ?array { if ( ! is_array( $block ) ) { return null; } $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); $model = $attrs[ self::ATTRIBUTE_KEY ] ?? null; return is_array( $model ) ? $model : null; } /** * Computes the responsive hide-classes for a model's device conditions. * * Device conditions list the devices a block should remain visible on; the * block is hidden on every other device via a `blocksieve-hide-on-*` class. With * "all", the block shows only on devices every device condition allows (the * other conditions have already passed). With "any", a passing non-device * condition shows the block everywhere; otherwise it shows on the union of * the selected devices. Returns an empty string when there is nothing to hide. * * @param array $model The visibility model (enabled, with conditions). * @return string Space-separated hide classes, or empty string. */ private function device_hide_classes( array $model ): string { $conditions = is_array( $model['conditions'] ) ? $model['conditions'] : array(); $relation = isset( $model['relation'] ) && 'any' === $model['relation'] ? 'any' : 'all'; $device_lists = array(); $others = array(); foreach ( $conditions as $condition ) { if ( is_array( $condition ) && ( $condition['type'] ?? '' ) === 'device' ) { $device_lists[] = $this->normalize_devices( $condition['value'] ?? null ); } else { $others[] = $condition; } } // A model that fails open (an unknown or malformed condition) is not // enforced at all — device hiding included, whatever the relation. if ( array() === $device_lists || ! $this->manager->is_valid( $model ) ) { return ''; } if ( 'any' === $relation && array() !== $others && $this->manager->matches( array_merge( $model, array( 'conditions' => $others ) ) ) ) { return ''; } $show = 'all' === $relation ? array_intersect( self::DEVICES, ...$device_lists ) : array_values( array_unique( array_merge( ...$device_lists ) ) ); $hide = array_diff( self::DEVICES, $show ); if ( array() === $hide ) { return ''; } $classes = array(); foreach ( $hide as $device ) { $classes[] = 'blocksieve-hide-on-' . $device; } return implode( ' ', $classes ); } /** * Normalises a device condition value into a list of device identifiers. * * A selection with no known device — empty, or made only of identifiers the * plugin does not recognise — has no effect and shows the block everywhere, * matching the editor hint and the monitoring report. Narrowing happens only * when at least one recognised device is selected. * * @param mixed $value Stored value. * @return string[] */ private function normalize_devices( $value ): array { if ( is_array( $value ) && isset( $value['devices'] ) && is_array( $value['devices'] ) ) { $devices = $value['devices']; } elseif ( is_string( $value ) && '' !== $value ) { $devices = array( $value ); // Legacy single-string value. } else { return self::DEVICES; } $devices = array_values( array_intersect( self::DEVICES, array_filter( $devices, 'is_string' ) ) ); return array() === $devices ? self::DEVICES : $devices; } }