You've already forked wp-bootstrap
feat: Bootstrap 5 block renderer, widget cards, and sidebar post layout (v1.1.0)
Add BlockRenderer class injecting Bootstrap classes into 8 core block types (table, button, buttons, image, search, quote, pullquote, list) via per-block render_block filters using WP_HTML_Tag_Processor. Add WidgetRenderer class wrapping sidebar widgets in Bootstrap card components with h4 heading hierarchy via dynamic_sidebar_params and widget_block_content filters. Add widget SCSS stylesheet for list styling, search input-group, tag cloud pills, and card-flush list positioning. Add single-sidebar.html.twig as the default post template with two-column Bootstrap layout (col-lg-8 content, col-lg-4 sidebar). Full-width available via template selection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
303
inc/Block/BlockRenderer.php
Normal file
303
inc/Block/BlockRenderer.php
Normal file
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
/**
|
||||
* Block Renderer.
|
||||
*
|
||||
* Injects Bootstrap 5 classes into WordPress core block HTML output
|
||||
* on the frontend. Uses WP_HTML_Tag_Processor for safe HTML manipulation.
|
||||
*
|
||||
* @package WPBootstrap\Block
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
namespace WPBootstrap\Block;
|
||||
|
||||
use WP_HTML_Tag_Processor;
|
||||
use WP_Block;
|
||||
|
||||
class BlockRenderer
|
||||
{
|
||||
/**
|
||||
* Map of WordPress preset color slugs to Bootstrap button variants.
|
||||
*/
|
||||
private const COLOR_VARIANTS = [
|
||||
'primary' => 'primary',
|
||||
'secondary' => 'secondary',
|
||||
'success' => 'success',
|
||||
'danger' => 'danger',
|
||||
'warning' => 'warning',
|
||||
'info' => 'info',
|
||||
'light' => 'light',
|
||||
'dark' => 'dark',
|
||||
];
|
||||
|
||||
/**
|
||||
* Register render_block filters for each supported block type.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
if ( is_admin() || wp_doing_ajax() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$blocks = $this->getBlockHandlers();
|
||||
|
||||
/**
|
||||
* Filters the map of block names to handler method names.
|
||||
*
|
||||
* Child themes can remove blocks or add new ones.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*
|
||||
* @param array $blocks Map of 'core/block-name' => 'methodName'.
|
||||
*/
|
||||
$blocks = apply_filters( 'wp_bootstrap_block_renderer_blocks', $blocks );
|
||||
|
||||
foreach ( $blocks as $blockName => $method ) {
|
||||
if ( method_exists( $this, $method ) ) {
|
||||
add_filter( "render_block_{$blockName}", [ $this, $method ], 10, 3 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default map of block names to handler methods.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getBlockHandlers(): array
|
||||
{
|
||||
return [
|
||||
'core/table' => 'renderTable',
|
||||
'core/button' => 'renderButton',
|
||||
'core/buttons' => 'renderButtons',
|
||||
'core/image' => 'renderImage',
|
||||
'core/search' => 'renderSearch',
|
||||
'core/quote' => 'renderQuote',
|
||||
'core/pullquote' => 'renderPullquote',
|
||||
'core/list' => 'renderList',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap table classes.
|
||||
*
|
||||
* Injects .table on <table>; adds .table-striped when WP stripes style is active.
|
||||
*/
|
||||
public function renderTable( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( 'table' ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'table' );
|
||||
|
||||
$className = $block['attrs']['className'] ?? '';
|
||||
if ( str_contains( $className, 'is-style-stripes' ) ) {
|
||||
$processor->add_class( 'table-striped' );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap button classes.
|
||||
*
|
||||
* Injects .btn + color variant on .wp-block-button__link.
|
||||
* Maps WP preset color slugs to Bootstrap btn-{variant} / btn-outline-{variant}.
|
||||
*/
|
||||
public function renderButton( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( [ 'class_name' => 'wp-block-button__link' ] ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'btn' );
|
||||
|
||||
$attrs = $block['attrs'] ?? [];
|
||||
|
||||
// Gradient buttons: just .btn, inline style handles the color.
|
||||
if ( ! empty( $attrs['gradient'] ) ) {
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
$className = $attrs['className'] ?? '';
|
||||
$isOutline = str_contains( $className, 'is-style-outline' );
|
||||
|
||||
if ( $isOutline ) {
|
||||
$colorSlug = $attrs['textColor'] ?? 'primary';
|
||||
$variant = self::COLOR_VARIANTS[ $colorSlug ] ?? 'primary';
|
||||
$processor->add_class( 'btn-outline-' . $variant );
|
||||
} else {
|
||||
$colorSlug = $attrs['backgroundColor'] ?? 'primary';
|
||||
$variant = self::COLOR_VARIANTS[ $colorSlug ] ?? 'primary';
|
||||
$processor->add_class( 'btn-' . $variant );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap flex utilities to button group wrapper.
|
||||
*/
|
||||
public function renderButtons( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( [ 'class_name' => 'wp-block-buttons' ] ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'd-flex' );
|
||||
$processor->add_class( 'flex-wrap' );
|
||||
$processor->add_class( 'gap-2' );
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add .img-fluid to block images.
|
||||
*/
|
||||
public function renderImage( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( 'img' ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'img-fluid' );
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap form-control and button classes to search block.
|
||||
*/
|
||||
public function renderSearch( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
// Add .input-group to the inner wrapper for seamless input + button.
|
||||
if ( $processor->next_tag( [ 'class_name' => 'wp-block-search__inside-wrapper' ] ) ) {
|
||||
$processor->add_class( 'input-group' );
|
||||
}
|
||||
|
||||
if ( $processor->next_tag( [ 'class_name' => 'wp-block-search__input' ] ) ) {
|
||||
$processor->add_class( 'form-control' );
|
||||
}
|
||||
|
||||
if ( $processor->next_tag( [ 'class_name' => 'wp-block-search__button' ] ) ) {
|
||||
$processor->add_class( 'btn' );
|
||||
$processor->add_class( 'btn-primary' );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap blockquote classes to quote block.
|
||||
*/
|
||||
public function renderQuote( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( 'blockquote' ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'blockquote' );
|
||||
|
||||
if ( $processor->next_tag( 'cite' ) ) {
|
||||
$processor->add_class( 'blockquote-footer' );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap blockquote classes to pullquote block.
|
||||
*/
|
||||
public function renderPullquote( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( 'blockquote' ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'blockquote' );
|
||||
|
||||
if ( $processor->next_tag( 'cite' ) ) {
|
||||
$processor->add_class( 'blockquote-footer' );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap list-group classes when list-group style is selected.
|
||||
*
|
||||
* Only modifies lists with the is-style-list-group block style.
|
||||
*/
|
||||
public function renderList( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$className = $block['attrs']['className'] ?? '';
|
||||
|
||||
if ( ! str_contains( $className, 'is-style-list-group' ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
$listTag = ! empty( $block['attrs']['ordered'] ) ? 'ol' : 'ul';
|
||||
if ( $processor->next_tag( $listTag ) ) {
|
||||
$processor->add_class( 'list-group' );
|
||||
}
|
||||
|
||||
while ( $processor->next_tag( 'li' ) ) {
|
||||
$processor->add_class( 'list-group-item' );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
}
|
||||
115
inc/Block/WidgetRenderer.php
Normal file
115
inc/Block/WidgetRenderer.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/**
|
||||
* Widget Renderer.
|
||||
*
|
||||
* Transforms sidebar widget wrappers into Bootstrap 5 card components
|
||||
* and adjusts block widget content (headings, lists) for Bootstrap styling.
|
||||
*
|
||||
* Card structure:
|
||||
* With title: card → card-body → h4.card-title → content
|
||||
* Without title: card → card-body → content
|
||||
*
|
||||
* The title is placed inside card-body as a card-title. This avoids
|
||||
* broken HTML when widgets omit the title (WordPress skips before_title
|
||||
* and after_title entirely when there is no title to output).
|
||||
*
|
||||
* @package WPBootstrap\Block
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
namespace WPBootstrap\Block;
|
||||
|
||||
use WP_HTML_Tag_Processor;
|
||||
|
||||
class WidgetRenderer
|
||||
{
|
||||
/**
|
||||
* Register filters for widget output transformation.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
if ( is_admin() || wp_doing_ajax() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter( 'dynamic_sidebar_params', [ $this, 'wrapWidgetInCard' ] );
|
||||
add_filter( 'widget_block_content', [ $this, 'processBlockWidgetContent' ], 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Restructure widget wrapper as a Bootstrap card.
|
||||
*
|
||||
* Uses card-body for all content with card-title for the heading.
|
||||
* This structure works correctly whether or not the widget outputs a title.
|
||||
*
|
||||
* Note: WordPress runs sprintf on before_widget BEFORE this filter,
|
||||
* so %1$s/%2$s are already replaced. We must use the processed values.
|
||||
*
|
||||
* @param array $params Sidebar parameters.
|
||||
* @return array Modified parameters.
|
||||
*/
|
||||
public function wrapWidgetInCard( array $params ): array
|
||||
{
|
||||
$widgetId = $params[0]['widget_id'] ?? '';
|
||||
$beforeWidget = $params[0]['before_widget'] ?? '';
|
||||
|
||||
// Extract widget-type classes (e.g. widget_block, widget_search)
|
||||
// from the already-processed before_widget, skipping generic
|
||||
// wrapper classes that we're replacing.
|
||||
$widgetClasses = '';
|
||||
if ( preg_match( '/class="([^"]*)"/', $beforeWidget, $matches ) ) {
|
||||
$original = array_filter( explode( ' ', $matches[1] ) );
|
||||
$skip = [ 'widget', 'mb-4' ];
|
||||
$kept = array_diff( $original, $skip );
|
||||
$widgetClasses = implode( ' ', $kept );
|
||||
}
|
||||
|
||||
$params[0]['before_widget'] = sprintf(
|
||||
'<div id="%s" class="card mb-3 widget %s"><div class="card-body">',
|
||||
esc_attr( $widgetId ),
|
||||
esc_attr( $widgetClasses )
|
||||
);
|
||||
$params[0]['after_widget'] = '</div></div>';
|
||||
$params[0]['before_title'] = '<h4 class="card-title h6 text-uppercase fw-semibold">';
|
||||
$params[0]['after_title'] = '</h4>';
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process block widget content to downgrade h2 headings to h4.
|
||||
*
|
||||
* Block widgets render their headings as <h2 class="wp-block-heading">.
|
||||
* Inside a sidebar card, h2 is too large — replace with h4 for proper
|
||||
* visual hierarchy.
|
||||
*
|
||||
* @param string $content Widget block content.
|
||||
* @param array $instance Widget instance data.
|
||||
* @param \WP_Widget $widget Widget object.
|
||||
* @return string Modified content.
|
||||
*/
|
||||
public function processBlockWidgetContent( string $content, array $instance, \WP_Widget $widget ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// Replace <h2 with <h4 and </h2> with </h4> for widget headings.
|
||||
$content = preg_replace(
|
||||
'/<h2(\s+class="[^"]*wp-block-heading[^"]*")/',
|
||||
'<h4$1',
|
||||
$content
|
||||
);
|
||||
$content = preg_replace(
|
||||
'/<\/h2>/',
|
||||
'</h4>',
|
||||
$content
|
||||
);
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user