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;
}
}
|