1 Commits

Author SHA1 Message Date
3165e60639 feat: Bootstrap 5 block renderer, widget cards, and sidebar post layout (v1.1.0)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m7s
Create Release Package / Build Release (push) Successful in 1m41s
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>
2026-02-28 23:43:43 +01:00
16 changed files with 955 additions and 49 deletions

View File

@@ -2,6 +2,31 @@
All notable changes to this project will be documented in this file.
## [1.1.0] - 2026-02-28
### Added
- **Block Renderer** (`inc/Block/BlockRenderer.php`): New class that injects Bootstrap 5 classes into WordPress core block HTML output on the frontend via per-block `render_block_{$name}` filters. Handles 8 block types:
- `core/table``.table` on `<table>`, `.table-striped` when stripes style is active
- `core/button``.btn` + `.btn-{variant}` or `.btn-outline-{variant}` mapped from WP preset color slugs
- `core/buttons``.d-flex .flex-wrap .gap-2` on button group wrapper
- `core/image``.img-fluid` on `<img>` for responsive images
- `core/search``.input-group` on inner wrapper, `.form-control` on input, `.btn .btn-primary` on button
- `core/quote``.blockquote` on `<blockquote>`, `.blockquote-footer` on `<cite>`
- `core/pullquote` — Same blockquote treatment inside `<figure>`
- `core/list``.list-group` + `.list-group-item` when `is-style-list-group` block style is selected
- **Widget Renderer** (`inc/Block/WidgetRenderer.php`): New class that transforms sidebar widgets into Bootstrap 5 card components via `dynamic_sidebar_params` and `widget_block_content` filters. Wraps each widget in a `.card > .card-body` structure with `.card-title` headings. Downgrades block widget `<h2>` headings to `<h4>` for proper sidebar visual hierarchy.
- **Widget SCSS** (`src/scss/_widgets.scss`): New stylesheet for sidebar widget Bootstrap styling — list-group-style list items with border separators, flush-to-card-edge list positioning, Bootstrap form-control styling for select dropdowns, search form input-group layout, tag cloud with pill badges, and secondary-color post dates.
- **List Group block style**: New "List Group" style registered for `core/list` blocks — applies Bootstrap `.list-group` and `.list-group-item` classes when selected in the editor.
- **Single post sidebar template** (`views/pages/single-sidebar.html.twig`): New two-column layout for blog posts with `col-lg-8` content area and `col-lg-4` sidebar. Includes all single post features (meta, thumbnail, tags, post navigation, comments, more posts). "More posts" section uses `row-cols-md-2` to fit the narrower column.
- **Extensibility**: `wp_bootstrap_block_renderer_blocks` filter allows child themes to add/remove block handler mappings.
### Changed
- **Post template default** (`inc/Template/TemplateController.php`): Blog posts now render with the sidebar layout by default (`single-sidebar.html.twig`). Posts assigned the "Full Width" template use `single.html.twig` instead. Template selection uses `get_page_template_slug()` with a `match` expression.
- **Sidebar data for posts** (`inc/Template/ContextBuilder.php`): Posts always receive sidebar data (recent posts, tags, widgets) regardless of template selection, ensuring the sidebar partial always has data available.
- **Widget SCSS import** (`src/scss/style.scss`): Added `_widgets` partial import between Bootstrap Icons and custom styles.
## [1.0.12] - 2026-02-28
### Fixed

View File

@@ -34,7 +34,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
Current version is **v1.0.11**. See `PLAN.md` for details.
Current version is **v1.1.0**. See `PLAN.md` for details.
## Technical Stack
@@ -234,6 +234,60 @@ Build steps (in order):
## Session History
### Session 19 — v1.1.0 Block Renderer, Widget Renderer & Sidebar Post Layout (2026-02-28)
**Completed:** Bootstrap 5 class injection for core blocks, sidebar widget card wrappers, widget SCSS styling, and sidebar-default post template.
**What was built:**
- **BlockRenderer** (`inc/Block/BlockRenderer.php`): Per-block `render_block_{$name}` filters inject Bootstrap classes into 8 core block types using `WP_HTML_Tag_Processor`. Supports button color mapping (WP preset slugs → Bootstrap btn-{variant}), striped tables, responsive images, input-group search forms, blockquote styling, and list-group block style. Extensible via `wp_bootstrap_block_renderer_blocks` filter.
- **WidgetRenderer** (`inc/Block/WidgetRenderer.php`): `dynamic_sidebar_params` filter wraps sidebar widgets in Bootstrap `.card > .card-body` structure. `widget_block_content` filter downgrades block widget `<h2 class="wp-block-heading">` to `<h4>` via preg_replace. Widget ID and type-specific classes extracted from the already-processed `before_widget` string.
- **Widget SCSS** (`src/scss/_widgets.scss`): Comprehensive widget styling — list items with border separators, flush-to-card-edge positioning (negating card-body padding), form-control selects, input-group search forms, pill-badge tag cloud, and secondary-color post dates. Handles both legacy and block widget nesting (`.wp-block-group > ul`).
- **List Group block style** (`functions.php`): `register_block_style('core/list', ...)` for the `is-style-list-group` style option in the editor.
- **Single post sidebar template** (`views/pages/single-sidebar.html.twig`): Two-column Bootstrap layout (8/4 split) with all single post features. "More posts" section uses `row-cols-md-2` for the narrower column.
- **Post template selection** (`inc/Template/TemplateController.php`): Posts default to sidebar layout; `page-full-width` template slug maps to full-width.
- **Sidebar data always loaded for posts** (`inc/Template/ContextBuilder.php`): `getSidebarData()` called unconditionally for `is_singular('post')`.
**Architecture decisions:**
- **Per-block filters over generic `render_block`**: `render_block_{$blockName}` only fires for the targeted block type, avoiding callback overhead on every block render. More maintainable — each handler is scoped to one block type.
- **`WP_HTML_Tag_Processor` over regex**: WordPress 6.7+ class provides safe, forward-only HTML manipulation. `add_class()` is idempotent (no duplicate classes). Handles malformed HTML gracefully.
- **Card-body with card-title, not card-header**: WordPress omits `before_title`/`after_title` entirely when a widget has no title. Using `card-header` for the title would leave an empty `<div class="card-header"></div>` for titleless widgets — broken HTML. Card-body with card-title inside works correctly in both cases.
- **`dynamic_sidebar_params` + regex extraction**: WordPress runs `sprintf` on `before_widget` BEFORE the `dynamic_sidebar_params` filter fires, so placeholder strings (`%1$s`, `%2$s`) are already replaced. Widget ID comes from `$params[0]['widget_id']`, type classes extracted via regex from the processed HTML.
- **Sidebar default for posts**: Blog posts are content-centric and benefit from sidebar context (recent posts, search, tags). Full-width is opt-in via "Full Width" template assignment.
**Key findings:**
- `WP_Block $instance` type hint is too strict for manual `apply_filters()` calls — WordPress passes `null` when filters are invoked outside the block rendering pipeline. Use `?WP_Block $instance = null`.
- Block widgets nest content inside `.wp-block-group` wrappers. CSS selectors like `.card-body > ul` won't match — need `.card-body > .wp-block-group > ul` for flush list positioning.
- `widget_block_content` filter (WordPress 5.8+) fires for block-based widgets only, allowing inner HTML modification without affecting legacy widgets.
- WordPress search block uses `.wp-block-search__inside-wrapper` as the input+button container — adding `.input-group` to this element creates a proper Bootstrap input-group.
- `@extend .btn; @extend .btn-primary` in SCSS works for search submit buttons because Bootstrap is imported before the widgets partial in the SCSS cascade.
**Files created:**
- `inc/Block/BlockRenderer.php` — 8 block handlers
- `inc/Block/WidgetRenderer.php` — card wrapper + heading downgrade
- `src/scss/_widgets.scss` — widget Bootstrap styling
- `views/pages/single-sidebar.html.twig` — two-column post template
**Files modified:**
- `functions.php` — init hooks for both renderers, list-group block style
- `src/scss/style.scss` — widgets import
- `inc/Template/TemplateController.php` — post template selection logic
- `inc/Template/ContextBuilder.php` — always load sidebar for posts
- `style.css` — version bump to 1.1.0
- `CHANGELOG.md`, `README.md`, `CLAUDE.md` — documentation
### Session 18 — v1.0.12 Admin Bar Offcanvas Fix (2026-02-28)
**Completed:** Scoped admin bar offcanvas padding to mobile viewports only.
**What was fixed:**
- `functions.php`: Added `@media (max-width: 991.98px)` wrapper to the admin bar offcanvas padding CSS so the extra padding does not appear on desktop where the offcanvas renders inline as a regular navbar.
### Session 17 — v1.0.11 Offcanvas Navigation & User Context (2026-02-28)
**Completed:** Switched mobile navigation from Bootstrap collapse to offcanvas, added logged-in user context to the header, and fixed admin bar overlap.

View File

@@ -10,8 +10,10 @@ A modern WordPress Block Theme built from scratch with Bootstrap 5. Features res
- **Style Variations** -- 15 color schemes (7 light, 7 dark, plus default) with live Design Editor customization
- **Block Patterns** -- 41 patterns across 11 categories (hero, features, CTA, testimonials, pricing, contact, text, layout, components, navigation, pages)
- **Bootstrap Icons** -- 2,000+ icons available via CSS classes (`bi bi-*`)
- **Block Styles** -- 17 custom styles mapping Bootstrap components to WordPress blocks
- **Custom Templates** -- Landing (no header/footer), full-width, hero, sidebar page templates
- **Block Renderer** -- Automatic Bootstrap 5 class injection on 8 core block types (table, button, image, search, quote, pullquote, list) via `render_block` filters
- **Widget Renderer** -- Sidebar widgets wrapped in Bootstrap cards with proper heading hierarchy
- **Block Styles** -- 18 custom styles mapping Bootstrap components to WordPress blocks (including List Group)
- **Custom Templates** -- Landing (no header/footer), full-width, hero, sidebar page templates; blog posts default to sidebar layout
- **Header/Footer Variations** -- Default, centered, transparent headers; default, minimal, multi-column footers
- **Navigation Styles** -- Dark navbar, offcanvas mobile navigation
- **Widget Area** -- Sidebar widget area manageable via WordPress admin, with built-in fallback
@@ -98,6 +100,8 @@ WordPress style variation colors are bridged to Bootstrap CSS custom properties
| `TemplateController` | Hooks `template_redirect`, resolves and renders Twig templates |
| `ContextBuilder` | Gathers WordPress data (posts, menus, pagination, comments, sidebar) |
| `NavWalker` | Converts flat menu items to nested tree for Bootstrap dropdowns |
| `BlockRenderer` | Injects Bootstrap 5 classes into core block HTML via `render_block` filters |
| `WidgetRenderer` | Wraps sidebar widgets in Bootstrap card components |
### Navigation Menus
@@ -110,7 +114,15 @@ If no menu is assigned, the primary location falls back to listing published pag
### Widget Areas
The theme registers a **Sidebar** widget area. When widgets are assigned via **Appearance > Widgets**, they replace the default sidebar content. When no widgets are assigned, the sidebar displays recent posts, a search form, and a tag cloud.
The theme registers a **Sidebar** widget area. When widgets are assigned via **Appearance > Widgets**, they replace the default sidebar content. When no widgets are assigned, the sidebar displays recent posts, a search form, and a tag cloud. All sidebar widgets are automatically wrapped in Bootstrap card components with consistent heading styles.
### Block Renderer
The `BlockRenderer` class hooks per-block `render_block_{$name}` filters (more performant than a single `render_block` filter) and uses WordPress's `WP_HTML_Tag_Processor` for safe, idempotent class injection. Only active on the frontend — admin, REST API, and AJAX requests are skipped. Child themes can modify the block-to-handler map via the `wp_bootstrap_block_renderer_blocks` filter.
### Widget Renderer
The `WidgetRenderer` class transforms sidebar widget output into Bootstrap card components. It hooks `dynamic_sidebar_params` for wrapper HTML and `widget_block_content` for inner content adjustments (heading level downgrade from `<h2>` to `<h4>`). The card structure uses `card-body` with `card-title` inside, ensuring valid HTML whether or not a widget outputs a title.
### Project Structure
@@ -118,6 +130,7 @@ The theme registers a **Sidebar** widget area. When widgets are assigned via **A
wp-bootstrap/
+-- assets/ Compiled CSS, JS, fonts
+-- inc/
| +-- Block/ BlockRenderer, WidgetRenderer
| +-- Template/ TemplateController, ContextBuilder, NavWalker
| +-- Twig/ TwigService singleton
+-- languages/ Translation files (.pot, .po)

View File

@@ -2443,7 +2443,7 @@ textarea.form-control-lg {
clip: rect(0, 0, 0, 0);
pointer-events: none;
}
.btn-check[disabled] + .btn, .btn-check:disabled + .btn {
.btn-check[disabled] + .btn, .widget .search-form .btn-check[disabled] + .search-submit, .btn-check:disabled + .btn, .widget .search-form .btn-check:disabled + .search-submit {
pointer-events: none;
filter: none;
opacity: 0.65;
@@ -2642,11 +2642,11 @@ textarea.form-control-lg {
.input-group > .form-floating:focus-within {
z-index: 5;
}
.input-group .btn {
.input-group .btn, .input-group .widget .search-form .search-submit, .widget .search-form .input-group .search-submit {
position: relative;
z-index: 2;
}
.input-group .btn:focus {
.input-group .btn:focus, .input-group .widget .search-form .search-submit:focus, .widget .search-form .input-group .search-submit:focus {
z-index: 5;
}
@@ -2668,7 +2668,8 @@ textarea.form-control-lg {
.input-group-lg > .form-control,
.input-group-lg > .form-select,
.input-group-lg > .input-group-text,
.input-group-lg > .btn {
.input-group-lg > .btn,
.widget .search-form .input-group-lg > .search-submit {
padding: 0.5rem 1rem;
font-size: 1.25rem;
border-radius: var(--bs-border-radius-lg);
@@ -2677,7 +2678,8 @@ textarea.form-control-lg {
.input-group-sm > .form-control,
.input-group-sm > .form-select,
.input-group-sm > .input-group-text,
.input-group-sm > .btn {
.input-group-sm > .btn,
.widget .search-form .input-group-sm > .search-submit {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
border-radius: var(--bs-border-radius-sm);
@@ -2893,7 +2895,7 @@ textarea.form-control-lg {
z-index: 4;
}
.btn {
.btn, .widget .search-form .search-submit {
--bs-btn-padding-x: 0.75rem;
--bs-btn-padding-y: 0.375rem;
--bs-btn-font-family: ;
@@ -2927,44 +2929,44 @@ textarea.form-control-lg {
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
.btn {
.btn, .widget .search-form .search-submit {
transition: none;
}
}
.btn:hover {
.btn:hover, .widget .search-form .search-submit:hover {
color: var(--bs-btn-hover-color);
background-color: var(--bs-btn-hover-bg);
border-color: var(--bs-btn-hover-border-color);
}
.btn-check + .btn:hover {
.btn-check + .btn:hover, .widget .search-form .btn-check + .search-submit:hover {
color: var(--bs-btn-color);
background-color: var(--bs-btn-bg);
border-color: var(--bs-btn-border-color);
}
.btn:focus-visible {
.btn:focus-visible, .widget .search-form .search-submit:focus-visible {
color: var(--bs-btn-hover-color);
background-color: var(--bs-btn-hover-bg);
border-color: var(--bs-btn-hover-border-color);
outline: 0;
box-shadow: var(--bs-btn-focus-box-shadow);
}
.btn-check:focus-visible + .btn {
.btn-check:focus-visible + .btn, .widget .search-form .btn-check:focus-visible + .search-submit {
border-color: var(--bs-btn-hover-border-color);
outline: 0;
box-shadow: var(--bs-btn-focus-box-shadow);
}
.btn-check:checked + .btn, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show {
.btn-check:checked + .btn, .widget .search-form .btn-check:checked + .search-submit, :not(.btn-check) + .btn:active, .widget .search-form :not(.btn-check) + .search-submit:active, .btn:first-child:active, .widget .search-form .search-submit:first-child:active, .btn.active, .widget .search-form .active.search-submit, .btn.show, .widget .search-form .show.search-submit {
color: var(--bs-btn-active-color);
background-color: var(--bs-btn-active-bg);
border-color: var(--bs-btn-active-border-color);
}
.btn-check:checked + .btn:focus-visible, :not(.btn-check) + .btn:active:focus-visible, .btn:first-child:active:focus-visible, .btn.active:focus-visible, .btn.show:focus-visible {
.btn-check:checked + .btn:focus-visible, .widget .search-form .btn-check:checked + .search-submit:focus-visible, :not(.btn-check) + .btn:active:focus-visible, .widget .search-form :not(.btn-check) + .search-submit:active:focus-visible, .btn:first-child:active:focus-visible, .widget .search-form .search-submit:first-child:active:focus-visible, .btn.active:focus-visible, .widget .search-form .active.search-submit:focus-visible, .btn.show:focus-visible, .widget .search-form .show.search-submit:focus-visible {
box-shadow: var(--bs-btn-focus-box-shadow);
}
.btn-check:checked:focus-visible + .btn {
.btn-check:checked:focus-visible + .btn, .widget .search-form .btn-check:checked:focus-visible + .search-submit {
box-shadow: var(--bs-btn-focus-box-shadow);
}
.btn:disabled, .btn.disabled, fieldset:disabled .btn {
.btn:disabled, .widget .search-form .search-submit:disabled, .btn.disabled, .widget .search-form .disabled.search-submit, fieldset:disabled .btn, fieldset:disabled .widget .search-form .search-submit, .widget .search-form fieldset:disabled .search-submit {
color: var(--bs-btn-disabled-color);
pointer-events: none;
background-color: var(--bs-btn-disabled-bg);
@@ -2972,7 +2974,7 @@ textarea.form-control-lg {
opacity: var(--bs-btn-disabled-opacity);
}
.btn-primary {
.btn-primary, .widget .search-form .search-submit {
--bs-btn-color: #FFFFFF;
--bs-btn-bg: #0d6efd;
--bs-btn-border-color: #0d6efd;
@@ -3266,14 +3268,14 @@ textarea.form-control-lg {
color: var(--bs-btn-hover-color);
}
.btn-lg, .btn-group-lg > .btn {
.btn-lg, .btn-group-lg > .btn, .widget .search-form .btn-group-lg > .search-submit {
--bs-btn-padding-y: 0.5rem;
--bs-btn-padding-x: 1rem;
--bs-btn-font-size: 1.25rem;
--bs-btn-border-radius: var(--bs-border-radius-lg);
}
.btn-sm, .btn-group-sm > .btn {
.btn-sm, .btn-group-sm > .btn, .widget .search-form .btn-group-sm > .search-submit {
--bs-btn-padding-y: 0.25rem;
--bs-btn-padding-x: 0.5rem;
--bs-btn-font-size: 0.875rem;
@@ -3640,23 +3642,35 @@ textarea.form-control-lg {
display: inline-flex;
vertical-align: middle;
}
.btn-group > .btn,
.btn-group-vertical > .btn {
.btn-group > .btn, .widget .search-form .btn-group > .search-submit,
.btn-group-vertical > .btn,
.widget .search-form .btn-group-vertical > .search-submit {
position: relative;
flex: 1 1 auto;
}
.btn-group > .btn-check:checked + .btn,
.btn-group > .btn-check:checked + .btn, .widget .search-form .btn-group > .btn-check:checked + .search-submit,
.btn-group > .btn-check:focus + .btn,
.widget .search-form .btn-group > .btn-check:focus + .search-submit,
.btn-group > .btn:hover,
.widget .search-form .btn-group > .search-submit:hover,
.btn-group > .btn:focus,
.widget .search-form .btn-group > .search-submit:focus,
.btn-group > .btn:active,
.widget .search-form .btn-group > .search-submit:active,
.btn-group > .btn.active,
.widget .search-form .btn-group > .active.search-submit,
.btn-group-vertical > .btn-check:checked + .btn,
.widget .search-form .btn-group-vertical > .btn-check:checked + .search-submit,
.btn-group-vertical > .btn-check:focus + .btn,
.widget .search-form .btn-group-vertical > .btn-check:focus + .search-submit,
.btn-group-vertical > .btn:hover,
.widget .search-form .btn-group-vertical > .search-submit:hover,
.btn-group-vertical > .btn:focus,
.widget .search-form .btn-group-vertical > .search-submit:focus,
.btn-group-vertical > .btn:active,
.btn-group-vertical > .btn.active {
.widget .search-form .btn-group-vertical > .search-submit:active,
.btn-group-vertical > .btn.active,
.widget .search-form .btn-group-vertical > .active.search-submit {
z-index: 1;
}
@@ -3672,19 +3686,23 @@ textarea.form-control-lg {
.btn-group {
border-radius: var(--bs-border-radius);
}
.btn-group > :not(.btn-check:first-child) + .btn,
.btn-group > :not(.btn-check:first-child) + .btn, .widget .search-form .btn-group > :not(.btn-check:first-child) + .search-submit,
.btn-group > .btn-group:not(:first-child) {
margin-left: calc(-1 * var(--bs-border-width));
}
.btn-group > .btn:not(:last-child):not(.dropdown-toggle),
.btn-group > .btn:not(:last-child):not(.dropdown-toggle), .widget .search-form .btn-group > .search-submit:not(:last-child):not(.dropdown-toggle),
.btn-group > .btn.dropdown-toggle-split:first-child,
.btn-group > .btn-group:not(:last-child) > .btn {
.widget .search-form .btn-group > .dropdown-toggle-split.search-submit:first-child,
.btn-group > .btn-group:not(:last-child) > .btn,
.widget .search-form .btn-group > .btn-group:not(:last-child) > .search-submit {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.btn-group > .btn:nth-child(n+3),
.btn-group > .btn:nth-child(n+3), .widget .search-form .btn-group > .search-submit:nth-child(n+3),
.btn-group > :not(.btn-check) + .btn,
.btn-group > .btn-group:not(:first-child) > .btn {
.widget .search-form .btn-group > :not(.btn-check) + .search-submit,
.btn-group > .btn-group:not(:first-child) > .btn,
.widget .search-form .btn-group > .btn-group:not(:first-child) > .search-submit {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@@ -3700,12 +3718,12 @@ textarea.form-control-lg {
margin-right: 0;
}
.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split {
.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split, .widget .search-form .btn-group-sm > .search-submit + .dropdown-toggle-split {
padding-right: 0.375rem;
padding-left: 0.375rem;
}
.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split {
.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split, .widget .search-form .btn-group-lg > .search-submit + .dropdown-toggle-split {
padding-right: 0.75rem;
padding-left: 0.75rem;
}
@@ -3715,22 +3733,25 @@ textarea.form-control-lg {
align-items: flex-start;
justify-content: center;
}
.btn-group-vertical > .btn,
.btn-group-vertical > .btn, .widget .search-form .btn-group-vertical > .search-submit,
.btn-group-vertical > .btn-group {
width: 100%;
}
.btn-group-vertical > .btn:not(:first-child),
.btn-group-vertical > .btn:not(:first-child), .widget .search-form .btn-group-vertical > .search-submit:not(:first-child),
.btn-group-vertical > .btn-group:not(:first-child) {
margin-top: calc(-1 * var(--bs-border-width));
}
.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),
.btn-group-vertical > .btn-group:not(:last-child) > .btn {
.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), .widget .search-form .btn-group-vertical > .search-submit:not(:last-child):not(.dropdown-toggle),
.btn-group-vertical > .btn-group:not(:last-child) > .btn,
.widget .search-form .btn-group-vertical > .btn-group:not(:last-child) > .search-submit {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.btn-group-vertical > .btn:nth-child(n+3),
.btn-group-vertical > .btn:nth-child(n+3), .widget .search-form .btn-group-vertical > .search-submit:nth-child(n+3),
.btn-group-vertical > :not(.btn-check) + .btn,
.btn-group-vertical > .btn-group:not(:first-child) > .btn {
.widget .search-form .btn-group-vertical > :not(.btn-check) + .search-submit,
.btn-group-vertical > .btn-group:not(:first-child) > .btn,
.widget .search-form .btn-group-vertical > .btn-group:not(:first-child) > .search-submit {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
@@ -4769,7 +4790,7 @@ textarea.form-control-lg {
display: none;
}
.btn .badge {
.btn .badge, .widget .search-form .search-submit .badge {
position: relative;
top: -1px;
}
@@ -6718,7 +6739,7 @@ textarea.form-control-lg {
background-color: currentcolor;
opacity: 0.5;
}
.placeholder.btn::before {
.placeholder.btn::before, .widget .search-form .placeholder.search-submit::before {
display: inline-block;
content: "";
}
@@ -20227,6 +20248,127 @@ h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6, blockquote, caption, figca
content: "爛";
}
.widget .card-title,
.widget .wp-block-heading {
margin-bottom: 0.75rem;
}
.widget ul,
.widget ol {
list-style: none;
padding-left: 0;
margin-bottom: 0;
}
.widget ul li,
.widget ol li {
padding: 0.5rem 1rem;
border-bottom: var(--bs-border-width) solid var(--bs-border-color);
}
.widget ul li:last-child,
.widget ol li:last-child {
border-bottom: 0;
}
.widget ul li a,
.widget ol li a {
text-decoration: none;
}
.widget ul li a:hover,
.widget ol li a:hover {
text-decoration: underline;
}
.widget .card-body > ul,
.widget .card-body > ol,
.widget .card-body > nav > ul,
.widget .card-body > .wp-block-group > ul,
.widget .card-body > .wp-block-group > ol {
margin: 0 calc(-1 * var(--bs-card-spacer-x)) calc(-1 * var(--bs-card-spacer-y));
}
.widget .card-body > ul li:first-child,
.widget .card-body > ol li:first-child,
.widget .card-body > nav > ul li:first-child,
.widget .card-body > .wp-block-group > ul li:first-child,
.widget .card-body > .wp-block-group > ol li:first-child {
border-top: var(--bs-border-width) solid var(--bs-border-color);
}
.widget select {
display: block;
width: 100%;
padding: 0.375rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
appearance: auto;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.widget select:focus {
border-color: rgb(134, 182.5, 254);
outline: 0;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
.widget .search-form {
display: flex;
gap: 0.5rem;
}
.widget .search-form .search-field {
flex: 1;
display: block;
width: 100%;
padding: 0.375rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
}
.widget .search-form .search-field:focus {
border-color: rgb(134, 182.5, 254);
outline: 0;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
.widget .wp-block-search .wp-block-search__label {
display: none;
}
.widget .tagcloud {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.widget .tagcloud a {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.875rem !important;
line-height: 1.5;
color: var(--bs-body-color);
background-color: var(--bs-tertiary-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius-pill);
text-decoration: none;
transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
}
.widget .tagcloud a:hover {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #fff;
}
.widget .post-date {
display: block;
font-size: 0.875em;
color: var(--bs-secondary-color);
}
.widget .recentcomments {
font-size: 0.875rem;
}
.wp-bootstrap-dark-mode-toggle {
display: inline-flex;
align-items: center;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -757,6 +757,12 @@ if ( ! function_exists( 'wp_bootstrap_block_styles' ) ) :
'inline_style' => '
.is-style-separator-wide { max-width: none; }',
) );
// core/list - List Group (Bootstrap).
register_block_style( 'core/list', array(
'name' => 'list-group',
'label' => __( 'List Group', 'wp-bootstrap' ),
) );
}
endif;
add_action( 'init', 'wp_bootstrap_block_styles' );
@@ -790,6 +796,40 @@ if ( ! function_exists( 'wp_bootstrap_init_templates' ) ) :
endif;
add_action( 'after_setup_theme', 'wp_bootstrap_init_templates' );
/**
* Initialize block renderer for Bootstrap 5 class injection.
*
* Hooks per-block render_block filters to inject Bootstrap classes
* (e.g. .table, .btn, .img-fluid) into core block HTML on the frontend.
*
* @since 1.1.0
*/
if ( ! function_exists( 'wp_bootstrap_init_block_renderer' ) ) :
function wp_bootstrap_init_block_renderer() {
if ( class_exists( '\\WPBootstrap\\Block\\BlockRenderer' ) ) {
new \WPBootstrap\Block\BlockRenderer();
}
}
endif;
add_action( 'after_setup_theme', 'wp_bootstrap_init_block_renderer' );
/**
* Initialize widget renderer for Bootstrap 5 card wrappers.
*
* Hooks dynamic_sidebar_params to wrap sidebar widgets in Bootstrap
* card components with proper heading structure.
*
* @since 1.1.0
*/
if ( ! function_exists( 'wp_bootstrap_init_widget_renderer' ) ) :
function wp_bootstrap_init_widget_renderer() {
if ( class_exists( '\\WPBootstrap\\Block\\WidgetRenderer' ) ) {
new \WPBootstrap\Block\WidgetRenderer();
}
}
endif;
add_action( 'after_setup_theme', 'wp_bootstrap_init_widget_renderer' );
/**
* Customize comment form fields with Bootstrap classes.
*

303
inc/Block/BlockRenderer.php Normal file
View 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();
}
}

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

View File

@@ -70,14 +70,19 @@ class ContextBuilder
$context['sidebar'] = $this->getSidebarData();
}
// Sidebar data for pages/posts using the "Page with Sidebar" template.
if (is_page() || is_singular('post')) {
// Sidebar data for pages using the "Page with Sidebar" template.
if (is_page()) {
$slug = get_page_template_slug();
if ($slug === 'page-sidebar') {
$context['sidebar'] = $this->getSidebarData();
}
}
// Posts always get sidebar data (sidebar is the default layout).
if (is_singular('post')) {
$context['sidebar'] = $this->getSidebarData();
}
return $context;
}

View File

@@ -83,7 +83,11 @@ class TemplateController
}
if (is_singular('post')) {
return 'pages/single.html.twig';
$slug = get_page_template_slug();
return match ($slug) {
'page-full-width' => 'pages/single.html.twig',
default => 'pages/single-sidebar.html.twig',
};
}
if (is_page()) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 284 KiB

145
src/scss/_widgets.scss Normal file
View File

@@ -0,0 +1,145 @@
// Widget Bootstrap 5 styling
// Targets sidebar widget inner content rendered by WordPress core widgets.
// Block widgets nest content inside .wp-block-group wrappers.
// Widget headings (block widgets use h4.wp-block-heading after WidgetRenderer transform)
.widget .card-title,
.widget .wp-block-heading {
margin-bottom: $spacer * 0.75;
}
// Widget lists (Recent Posts, Archives, Categories, Recent Comments)
// Covers both legacy (ul direct child) and block widget (ul inside .wp-block-group)
.widget ul,
.widget ol {
list-style: none;
padding-left: 0;
margin-bottom: 0;
li {
padding: $list-group-item-padding-y $list-group-item-padding-x;
border-bottom: var(--bs-border-width) solid var(--bs-border-color);
&:last-child {
border-bottom: 0;
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
// Flush lists to card edges (negate card-body padding)
// Handles both direct children and block widget nesting (.wp-block-group > ul)
.widget .card-body > ul,
.widget .card-body > ol,
.widget .card-body > nav > ul,
.widget .card-body > .wp-block-group > ul,
.widget .card-body > .wp-block-group > ol {
margin: 0 calc(-1 * var(--bs-card-spacer-x)) calc(-1 * var(--bs-card-spacer-y));
li:first-child {
border-top: var(--bs-border-width) solid var(--bs-border-color);
}
}
// Widget select dropdowns (Archives dropdown, Categories dropdown)
.widget select {
display: block;
width: 100%;
padding: $input-padding-y $input-padding-x;
font-size: $input-font-size;
line-height: $input-line-height;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
appearance: auto;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:focus {
border-color: $input-focus-border-color;
outline: 0;
box-shadow: $input-focus-box-shadow;
}
}
// Widget search form (legacy get_search_form() widgets)
.widget .search-form {
display: flex;
gap: 0.5rem;
.search-field {
flex: 1;
display: block;
width: 100%;
padding: $input-padding-y $input-padding-x;
font-size: $input-font-size;
line-height: $input-line-height;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
&:focus {
border-color: $input-focus-border-color;
outline: 0;
box-shadow: $input-focus-box-shadow;
}
}
.search-submit {
@extend .btn;
@extend .btn-primary;
}
}
// Block search widget — hide label, make input-group flush
.widget .wp-block-search {
.wp-block-search__label {
display: none;
}
}
// Tag cloud
.widget .tagcloud {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
a {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: $font-size-sm !important; // Override inline font-size from WP
line-height: 1.5;
color: var(--bs-body-color);
background-color: var(--bs-tertiary-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius-pill);
text-decoration: none;
transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
&:hover {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #fff;
}
}
}
// Post date in Recent Posts widget
.widget .post-date {
display: block;
font-size: $small-font-size;
color: var(--bs-secondary-color);
}
// Recent Comments styling
.widget .recentcomments {
font-size: $font-size-sm;
}

View File

@@ -18,5 +18,8 @@
// 5. Bootstrap Icons
@import "bootstrap-icons/font/bootstrap-icons";
// 6. Custom styles
// 6. Widget styles
@import "widgets";
// 7. Custom styles
@import "custom";

View File

@@ -7,7 +7,7 @@ Description: A modern WordPress Block Theme built from scratch with Bootstrap 5.
Requires at least: 6.7
Tested up to: 6.7
Requires PHP: 8.3
Version: 1.0.12
Version: 1.1.0
License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html
Text Domain: wp-bootstrap

View File

@@ -0,0 +1,57 @@
{% extends 'base.html.twig' %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-lg-8">
<article class="py-4">
<header class="mb-4">
<h1>{{ post.title }}</h1>
{% include 'partials/meta.html.twig' %}
</header>
{% if post.thumbnail %}
<figure class="mb-4">
<img src="{{ post.thumbnail }}" class="img-fluid rounded post-thumbnail"
alt="{{ post.title|e('html_attr') }}"
loading="lazy">
</figure>
{% endif %}
<div class="post-content">
{{ post.content|raw }}
</div>
{% if post.tags|length > 0 %}
<div class="mt-4 mb-4">
{% for tag in post.tags %}
<a href="{{ tag.url }}" class="badge bg-secondary text-decoration-none me-1">
{{ tag.name }}
</a>
{% endfor %}
</div>
{% endif %}
{% include 'partials/post-navigation.html.twig' %}
{% include 'partials/comments.html.twig' %}
</article>
{% if more_posts is defined and more_posts|length > 0 %}
<section class="py-5 border-top">
<h2 class="h4 mb-4">{{ __('More posts') }}</h2>
<div class="row row-cols-1 row-cols-md-2 g-4">
{% for post in more_posts %}
<div class="col">
{% include 'components/card-post-grid.html.twig' with {'post': post} only %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
</div>
<div class="col-lg-4">
{% include 'partials/sidebar.html.twig' %}
</div>
</div>
</div>
{% endblock %}