v0.1.1 - Bootstrap frontend rendering via Twig templates
All checks were successful
Create Release Package / PHP Lint (push) Successful in 49s
Create Release Package / Build Release (push) Successful in 1m18s

Replace FSE block markup on the frontend with proper Bootstrap 5 HTML
rendered through Twig templates. The Site Editor remains functional for
admin editing while the public site outputs Bootstrap navbar, cards,
pagination, grid layout, and responsive components.

New PHP classes: TemplateController, ContextBuilder, NavWalker
New Twig templates: 20 files (base, pages, partials, components)
Enhanced TwigService with WordPress functions and globals

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-02-08 15:11:00 +01:00
parent d069a203b4
commit cb288d6e74
32 changed files with 1439 additions and 29 deletions

View File

@@ -2,6 +2,34 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.1.1] - 2026-02-08
### Added
- Twig-based frontend rendering via `template_redirect` hook, bypassing FSE block markup on the frontend while preserving Site Editor functionality
- `TemplateController` class: resolves and renders Twig templates for all page types (home, single, page, archive, search, 404)
- `ContextBuilder` class: gathers WordPress data (posts, menus, pagination, comments, sidebar, archive info) into structured arrays for Twig
- `NavWalker` class: converts flat `wp_get_nav_menu_items()` into nested tree for Bootstrap dropdown menus
- 20 Twig templates with proper Bootstrap 5 HTML: base layout, 5 page templates, 9 partials (header, footer, pagination, sidebar, comments, search form, dark mode toggle, meta, post navigation), 3 components (post card, post grid card, post loop)
- Bootstrap 5 navbar with responsive collapse, brand, dropdown support, and dark mode toggle
- Bootstrap 5 card components for post listings
- Bootstrap 5 pagination component
- Bootstrap 5 comment section with threaded replies and Bootstrap-styled form fields
- Bootstrap 5 sidebar with recent posts, search, and tag cloud (badges)
- Previous/next post navigation and "More posts" grid on single posts
- WordPress functions in Twig: `wp_head`, `wp_footer`, `wp_body_open`, `language_attributes`, `body_class`, `home_url`, `get_bloginfo`, `get_search_query`, `wp_kses_post`, `number_format_i18n`, `_n`
- Twig globals: `site_name`, `site_description`, `site_url`, `theme_uri`, `charset`, `current_year`
- Twig filters: `wpautop`, `wp_kses_post`
- `primary` and `footer` navigation menu locations
- Comment form fields filter for Bootstrap classes (`form-control`, `form-label`, `form-check`, `btn`)
- Fallback menu from published pages when no menu is assigned
- Sidebar layout detection for "Blog with Sidebar" template
- README.md with project documentation
### Changed
- Enhanced `TwigService` with WordPress output-buffering functions, globals, and filters
## [0.1.0] - 2026-02-08 ## [0.1.0] - 2026-02-08
### Added ### Added

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. **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.
Next milestone is **v0.2.0 - Design Editor**. See `PLAN.md` for details. Current version is **v0.1.1** - Bootstrap Frontend Rendering. Next milestone is **v0.2.0 - Design Editor**. See `PLAN.md` for details.
## Technical Stack ## Technical Stack
@@ -187,9 +187,35 @@ Build steps (in order):
- **Style variations:** JSON files in `styles/` directory. All 4 variations use the same 10 color slug names (base, contrast, primary, secondary, success, danger, warning, info, light, dark) to ensure patterns work across all schemes. - **Style variations:** JSON files in `styles/` directory. All 4 variations use the same 10 color slug names (base, contrast, primary, secondary, success, danger, warning, info, light, dark) to ensure patterns work across all schemes.
- **Fonts:** Inter (sans-serif) and Lora (serif) variable fonts bundled as `.woff2` in `assets/fonts/`. Declared via `fontFace` in `theme.json` with `font-display: swap`. - **Fonts:** Inter (sans-serif) and Lora (serif) variable fonts bundled as `.woff2` in `assets/fonts/`. Declared via `fontFace` in `theme.json` with `font-display: swap`.
- **Patterns:** PHP files in `patterns/` with WordPress block markup and i18n. Hidden patterns (prefixed `hidden-`) are reusable components not shown in the pattern inserter. - **Patterns:** PHP files in `patterns/` with WordPress block markup and i18n. Hidden patterns (prefixed `hidden-`) are reusable components not shown in the pattern inserter.
- **Twig frontend rendering:** `TemplateController` hooks `template_redirect` to intercept frontend requests and render Bootstrap 5 HTML via Twig, bypassing FSE block markup. FSE templates remain for the Site Editor. WordPress functions that produce output (`wp_head`, `wp_footer`, `body_class`, `language_attributes`) are captured via `ob_start()`/`ob_get_clean()` and passed to Twig as safe HTML strings.
- **Navigation menus:** `NavWalker` converts flat `wp_get_nav_menu_items()` into a nested tree for Bootstrap dropdown rendering. Falls back to listing published pages when no menu is assigned.
- **Docker development:** WordPress runs in Docker container `jobroom-wordpress`. The theme directory must be bind-mounted via `compose.override.yaml` (absolute path) for live changes to be visible.
## Session History ## Session History
### Session 3 — v0.1.1 Bootstrap Frontend Rendering (2026-02-08)
**Completed:** Full Twig-based Bootstrap 5 frontend rendering, replacing FSE block markup on the public-facing site.
**What was built:**
- `TemplateController` class hooking `template_redirect` to render Twig templates for all page types
- `ContextBuilder` class gathering WordPress data (posts, menus, pagination, comments, sidebar, archive info) into structured arrays
- `NavWalker` class converting flat menu items to nested tree for Bootstrap dropdown menus
- 20 Twig templates: base layout, 5 page templates (index, single, page, archive, search, 404), 9 partials (header, footer, pagination, sidebar, comments, search form, dark mode toggle, meta, post navigation), 3 components (post card, grid card, post loop)
- Enhanced `TwigService` with WordPress output-buffering functions, globals, and filters
- Navigation menu locations (primary, footer) with pages fallback
- Comment form Bootstrap styling filter
- README.md project documentation
**Key learnings:**
- `template_redirect` + `exit()` cleanly bypasses FSE rendering on frontend while preserving Site Editor functionality
- WordPress functions that produce output (`wp_head`, `wp_footer`, `body_class`, `language_attributes`) must be captured via `ob_start()`/`ob_get_clean()` for use in Twig, and marked with `is_safe => html`
- With `optimize-autoloader: true` in `composer.json`, new PSR-4 classes require `composer dump-autoload` to regenerate the static classmap
- Docker bind mounts require absolute paths in `compose.override.yaml` -- relative paths create empty directories
- Post content is rendered via `apply_filters('the_content', get_the_content())` which processes Gutenberg blocks into standard HTML that Bootstrap CSS handles natively
### Session 2 — v0.1.0 Core Theme (2026-02-08) ### Session 2 — v0.1.0 Core Theme (2026-02-08)
**Completed:** Full v0.1.0 milestone implementation. **Completed:** Full v0.1.0 milestone implementation.

17
PLAN.md
View File

@@ -10,10 +10,12 @@ WP Bootstrap is a modern WordPress Block Theme built from scratch with Bootstrap
Full Site Editing (FSE) Block Theme following the WordPress 6.x template hierarchy: Full Site Editing (FSE) Block Theme following the WordPress 6.x template hierarchy:
- **Templates** (`templates/`): HTML files with WordPress block markup - **Templates** (`templates/`): HTML files with WordPress block markup (Site Editor)
- **Template Parts** (`parts/`): Reusable header/footer components - **Template Parts** (`parts/`): Reusable header/footer components (Site Editor)
- **Patterns** (`patterns/`): PHP files with block markup and i18n support - **Patterns** (`patterns/`): PHP files with block markup and i18n support
- **Design Tokens** (`theme.json`): Colors, typography, spacing mapped to Bootstrap 5 - **Design Tokens** (`theme.json`): Colors, typography, spacing mapped to Bootstrap 5
- **Twig Templates** (`views/`): Bootstrap 5 HTML rendered on the frontend via `template_redirect`
- **Template Engine** (`inc/Template/`): Controller, context builder, and nav walker for Twig rendering
### Technology Stack ### Technology Stack
@@ -52,6 +54,17 @@ node_modules/bootstrap/dist/js/ → copyfiles → assets/js/bootstrap.bundle.min
- [x] Sidebar template part - [x] Sidebar template part
- [x] Enhanced typography settings - [x] Enhanced typography settings
### v0.1.1 - Bootstrap Frontend Rendering (Complete)
- [x] Twig-based frontend rendering via `template_redirect` hook
- [x] `TemplateController`, `ContextBuilder`, `NavWalker` PHP classes
- [x] 20 Twig templates (base layout, pages, partials, components)
- [x] Bootstrap 5 navbar, cards, pagination, comments, sidebar
- [x] Enhanced `TwigService` with WordPress functions and globals
- [x] Navigation menu locations (primary, footer)
- [x] Comment form Bootstrap styling
- [x] README.md project documentation
### v0.2.0 - Design Editor ### v0.2.0 - Design Editor
- [ ] Full Design Editor compatibility - [ ] Full Design Editor compatibility

106
README.md
View File

@@ -1,21 +1,24 @@
# WP Bootstrap # WP Bootstrap
A modern WordPress Block Theme built from scratch with Bootstrap 5. A modern WordPress Block Theme built from scratch with Bootstrap 5. Features responsive design, dark mode support, Twig template rendering, and full compatibility with the WordPress Site Editor.
## Features ## Features
- Full Site Editing (FSE) support - **Bootstrap 5 Frontend** -- Proper Bootstrap 5 HTML (navbar, cards, pagination, grid) rendered via Twig templates
- Bootstrap 5 CSS and JavaScript - **Dark Mode** -- Toggle with localStorage persistence and `prefers-color-scheme` support
- Responsive design - **Full Site Editing** -- Compatible with the WordPress Site Editor for admin editing
- Dark mode ready (Bootstrap 5.3 dark mode variables) - **Block Patterns** -- 16 patterns across 7 categories (hero, features, CTA, testimonials, pricing, contact, text)
- Twig 3.0 template engine - **Block Styles** -- 17 custom styles mapping Bootstrap components to WordPress blocks
- WordPress i18n support - **Style Variations** -- 4 color schemes: Ocean, Forest, Sunset, Midnight
- Gitea CI/CD automated releases - **Responsive** -- Mobile-first design with Bootstrap's responsive grid
- **Translation Ready** -- Full i18n support with `en_US` and `de_CH` translations
## Requirements ## Requirements
- WordPress 6.7 or higher - WordPress 6.7 or higher
- PHP 8.3 or higher - PHP 8.3 or higher
- Composer
- Node.js 20+
## Installation ## Installation
@@ -35,6 +38,8 @@ npm install
npm run build npm run build
``` ```
Activate the theme in **Appearance > Themes** in the WordPress admin.
## Development ## Development
### Prerequisites ### Prerequisites
@@ -48,33 +53,86 @@ npm run build
| Command | Description | | Command | Description |
| --- | --- | | --- | --- |
| `npm run build` | Compile SCSS, minify CSS, copy Bootstrap JS | | `npm run build` | Full production build (copy JS, compile SCSS, minify CSS) |
| `npm run dev` | Watch SCSS files and recompile on changes | | `npm run dev` | Watch SCSS files and recompile on changes |
| `npm run scss` | Compile SCSS only | | `npm run scss` | Compile SCSS only |
| `npm run postcss` | Minify CSS with Autoprefixer | | `npm run postcss` | Minify CSS with Autoprefixer and cssnano |
| `composer install` | Install PHP dependencies | | `composer install` | Install PHP dependencies (Twig) |
### Build Pipeline
1. `copy:js` -- Copy Bootstrap JS bundle from `node_modules` to `assets/js/`
2. `copy:theme-js` -- Copy theme JS (dark-mode.js) from `src/js/` to `assets/js/`
3. `scss` -- Compile SCSS (`src/scss/`) to CSS (`assets/css/`)
4. `postcss` -- Autoprefixer + cssnano minification to `assets/css/style.min.css`
## Architecture
### Frontend Rendering
The theme uses a dual-rendering approach:
- **Site Editor (admin):** FSE block templates in `templates/` and `parts/` for visual editing
- **Frontend (public):** Twig templates in `views/` render Bootstrap 5 HTML via the `template_redirect` hook
The `TemplateController` intercepts frontend requests and renders the appropriate Twig template with data gathered by `ContextBuilder`. FSE templates remain untouched for the WordPress admin editor.
### Key PHP Classes
| Class | Purpose |
| --- | --- |
| `TwigService` | Singleton Twig environment with WordPress functions and globals |
| `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 |
### Navigation Menus
Register menus in **Appearance > Menus**:
- **Primary Navigation** -- Displayed in the Bootstrap navbar with dropdown support
- **Footer Navigation** -- Displayed in the footer
If no menu is assigned, the primary location falls back to listing published pages.
### Project Structure ### Project Structure
```txt ```txt
wp-bootstrap/ wp-bootstrap/
├── assets/ Compiled CSS, JS, and images +-- assets/ Compiled CSS, JS, fonts
├── inc/ PHP classes (PSR-4 autoloaded) +-- inc/
├── languages/ Translation files (.pot, .po) | +-- Template/ TemplateController, ContextBuilder, NavWalker
├── parts/ FSE template parts (header, footer) | +-- Twig/ TwigService singleton
├── patterns/ Block patterns +-- languages/ Translation files (.pot, .po)
├── src/scss/ SCSS source files +-- patterns/ Block patterns (PHP)
├── templates/ FSE page templates +-- parts/ FSE template parts (header, footer, sidebar)
├── views/ Twig templates +-- src/
├── functions.php Theme bootstrap | +-- js/ Source JavaScript
├── style.css Theme metadata | +-- scss/ Source SCSS
└── theme.json Design tokens and settings +-- styles/ Style variations (JSON)
+-- templates/ FSE templates (HTML)
+-- views/ Twig templates (Bootstrap 5 HTML)
| +-- base.html.twig
| +-- pages/ Page templates (index, single, page, archive, search, 404)
| +-- partials/ Reusable parts (header, footer, pagination, sidebar, etc.)
| +-- components/ UI components (post card, post loop)
+-- functions.php Theme bootstrap
+-- style.css Theme metadata
+-- theme.json Design tokens and settings
``` ```
## Technology Stack
- **PHP 8.3+** with PSR-4 autoloading
- **Twig 3.0** via Composer
- **Bootstrap 5.3+** CSS & JS (served locally)
- **Dart Sass** for SCSS compilation
- **PostCSS** with Autoprefixer and cssnano
## License ## License
GPL-2.0-or-later GPL-2.0-or-later. See <http://www.gnu.org/licenses/gpl-2.0.html>.
## Author ## Author
Marco Graetsch - [src.bundespruefstelle.ch/magdev](https://src.bundespruefstelle.ch/magdev) Marco Graetsch - <https://src.bundespruefstelle.ch/magdev>

View File

@@ -34,6 +34,12 @@ if ( ! function_exists( 'wp_bootstrap_setup' ) ) :
// Add editor styles. // Add editor styles.
add_editor_style( 'assets/css/editor-style.css' ); add_editor_style( 'assets/css/editor-style.css' );
// Register navigation menu locations.
register_nav_menus( array(
'primary' => __( 'Primary Navigation', 'wp-bootstrap' ),
'footer' => __( 'Footer Navigation', 'wp-bootstrap' ),
) );
} }
endif; endif;
add_action( 'after_setup_theme', 'wp_bootstrap_setup' ); add_action( 'after_setup_theme', 'wp_bootstrap_setup' );
@@ -315,3 +321,62 @@ if ( ! function_exists( 'wp_bootstrap_init_twig' ) ) :
} }
endif; endif;
add_action( 'after_setup_theme', 'wp_bootstrap_init_twig' ); add_action( 'after_setup_theme', 'wp_bootstrap_init_twig' );
/**
* Initialize Twig template controller for frontend rendering.
*
* Hooks into template_redirect to render Bootstrap 5 HTML
* via Twig templates instead of FSE block markup on the frontend.
*
* @since 0.1.1
*/
if ( ! function_exists( 'wp_bootstrap_init_templates' ) ) :
function wp_bootstrap_init_templates() {
if ( class_exists( '\\WPBootstrap\\Template\\TemplateController' ) ) {
new \WPBootstrap\Template\TemplateController();
}
}
endif;
add_action( 'after_setup_theme', 'wp_bootstrap_init_templates' );
/**
* Customize comment form fields with Bootstrap classes.
*
* @since 0.1.1
*
* @param array $fields Default comment form fields.
* @return array Modified fields with Bootstrap classes.
*/
if ( ! function_exists( 'wp_bootstrap_comment_form_fields' ) ) :
function wp_bootstrap_comment_form_fields( array $fields ): array {
$commenter = wp_get_current_commenter();
$required = get_option( 'require_name_email' );
$req_attr = $required ? ' required' : '';
$fields['author'] = '<div class="mb-3">'
. '<label for="author" class="form-label">' . __( 'Name', 'wp-bootstrap' ) . ( $required ? ' <span class="text-danger">*</span>' : '' ) . '</label>'
. '<input id="author" name="author" type="text" class="form-control" value="' . esc_attr( $commenter['comment_author'] ) . '"' . $req_attr . '>'
. '</div>';
$fields['email'] = '<div class="mb-3">'
. '<label for="email" class="form-label">' . __( 'Email', 'wp-bootstrap' ) . ( $required ? ' <span class="text-danger">*</span>' : '' ) . '</label>'
. '<input id="email" name="email" type="email" class="form-control" value="' . esc_attr( $commenter['comment_author_email'] ) . '"' . $req_attr . '>'
. '</div>';
$fields['url'] = '<div class="mb-3">'
. '<label for="url" class="form-label">' . __( 'Website', 'wp-bootstrap' ) . '</label>'
. '<input id="url" name="url" type="url" class="form-control" value="' . esc_attr( $commenter['comment_author_url'] ) . '">'
. '</div>';
if ( isset( $fields['cookies'] ) ) {
$fields['cookies'] = '<div class="mb-3 form-check">'
. '<input id="wp-comment-cookies-consent" name="wp-comment-cookies-consent" type="checkbox" class="form-check-input" value="yes">'
. '<label for="wp-comment-cookies-consent" class="form-check-label">'
. __( 'Save my name, email, and website in this browser for the next time I comment.', 'wp-bootstrap' )
. '</label></div>';
}
return $fields;
}
endif;
add_filter( 'comment_form_default_fields', 'wp_bootstrap_comment_form_fields' );

View File

@@ -0,0 +1,471 @@
<?php
/**
* Twig Template Context Builder.
*
* Gathers WordPress data into structured arrays for Twig templates.
*
* @package WPBootstrap
* @since 0.1.1
*/
namespace WPBootstrap\Template;
class ContextBuilder
{
private NavWalker $navWalker;
public function __construct()
{
$this->navWalker = new NavWalker();
}
/**
* Build the complete context for the current request.
*/
public function build(): array
{
$context = [
'site' => $this->getSiteData(),
'menu' => $this->getMenuData('primary'),
'footer_menu' => $this->getMenuData('footer'),
'dark_mode' => true,
'layout' => 'default',
];
if (is_singular()) {
$context['post'] = $this->getPostData();
if (is_singular('post')) {
$context['comments'] = $this->getCommentsData();
$context['post_navigation'] = $this->getPostNavigation();
$context['more_posts'] = $this->getMorePosts();
}
}
if (is_home() || is_archive() || is_search()) {
$context['posts'] = $this->getPostsLoop();
$context['pagination'] = $this->getPagination();
}
if (is_archive()) {
$context['archive'] = $this->getArchiveData();
}
if (is_search()) {
$context['search_query'] = get_search_query();
}
// Sidebar layout detection.
if (is_home()) {
$pageId = (int) get_option('page_for_posts');
if ($pageId) {
$templateSlug = get_page_template_slug($pageId);
if ($templateSlug === 'home-sidebar') {
$context['layout'] = 'sidebar';
}
}
$context['sidebar'] = $this->getSidebarData();
}
return $context;
}
/**
* Get global site information.
*/
private function getSiteData(): array
{
return [
'name' => get_bloginfo('name'),
'description' => get_bloginfo('description'),
'url' => home_url('/'),
'charset' => get_bloginfo('charset'),
];
}
/**
* Get navigation menu items for a location.
*/
private function getMenuData(string $location): array
{
$locations = get_nav_menu_locations();
if (isset($locations[$location])) {
$menu = wp_get_nav_menu_object($locations[$location]);
if ($menu) {
$items = wp_get_nav_menu_items($menu->term_id);
if ($items) {
return $this->navWalker->buildTree($items);
}
}
}
// Fallback for primary: list top-level pages.
if ($location === 'primary') {
return $this->getPagesFallback();
}
return [];
}
/**
* Fallback menu from published pages.
*/
private function getPagesFallback(): array
{
$pages = get_pages([
'sort_column' => 'menu_order,post_title',
'parent' => 0,
]);
$items = [];
foreach ($pages as $page) {
$items[] = [
'id' => $page->ID,
'title' => $page->post_title,
'url' => get_permalink($page->ID),
'target' => '',
'classes' => '',
'active' => is_page($page->ID),
'children' => [],
];
}
return $items;
}
/**
* Get single post/page data.
*/
private function getPostData(): array
{
global $post;
return [
'id' => $post->ID,
'title' => get_the_title(),
'url' => get_permalink(),
'content' => apply_filters('the_content', get_the_content()),
'excerpt' => get_the_excerpt(),
'date' => get_the_date(),
'date_iso' => get_the_date('c'),
'modified' => get_the_modified_date(),
'author' => [
'name' => get_the_author(),
'url' => get_author_posts_url(get_the_author_meta('ID')),
],
'thumbnail' => get_the_post_thumbnail_url($post->ID, 'large') ?: '',
'categories' => $this->getTermsList('category'),
'tags' => $this->getTermsList('post_tag'),
'type' => get_post_type(),
];
}
/**
* Get posts for the main query loop.
*/
private function getPostsLoop(): array
{
global $wp_query;
$posts = [];
if ($wp_query->have_posts()) {
while ($wp_query->have_posts()) {
$wp_query->the_post();
$posts[] = [
'id' => get_the_ID(),
'title' => get_the_title(),
'url' => get_permalink(),
'excerpt' => get_the_excerpt(),
'date' => get_the_date(),
'date_iso' => get_the_date('c'),
'author' => [
'name' => get_the_author(),
'url' => get_author_posts_url(get_the_author_meta('ID')),
],
'thumbnail' => get_the_post_thumbnail_url(null, 'medium_large') ?: '',
'categories' => $this->getTermsList('category'),
'tags' => $this->getTermsList('post_tag'),
'read_more' => __('Read more', 'wp-bootstrap'),
];
}
wp_reset_postdata();
}
return $posts;
}
/**
* Build pagination data.
*/
private function getPagination(): array
{
global $wp_query;
$totalPages = (int) $wp_query->max_num_pages;
$currentPage = max(1, get_query_var('paged'));
if ($totalPages <= 1) {
return [];
}
$pages = [];
for ($i = 1; $i <= $totalPages; $i++) {
$pages[] = [
'number' => $i,
'url' => get_pagenum_link($i),
'is_current' => ($i === $currentPage),
];
}
return [
'pages' => $pages,
'current' => $currentPage,
'total' => $totalPages,
'prev_url' => ($currentPage > 1) ? get_pagenum_link($currentPage - 1) : null,
'next_url' => ($currentPage < $totalPages) ? get_pagenum_link($currentPage + 1) : null,
'prev_text' => __('Previous', 'wp-bootstrap'),
'next_text' => __('Next', 'wp-bootstrap'),
];
}
/**
* Get archive page data.
*/
private function getArchiveData(): array
{
return [
'title' => get_the_archive_title(),
'description' => get_the_archive_description(),
];
}
/**
* Get comments for the current post.
*/
private function getCommentsData(): array
{
$postId = get_the_ID();
$count = (int) get_comments_number($postId);
$comments = get_comments([
'post_id' => $postId,
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
]);
return [
'list' => $this->buildCommentTree($comments),
'count' => $count,
'title' => sprintf(
_n('%s Comment', '%s Comments', $count, 'wp-bootstrap'),
number_format_i18n($count)
),
'form' => $this->getCommentFormHtml(),
'is_open' => comments_open($postId),
];
}
/**
* Build a nested comment tree from flat comments.
*/
private function buildCommentTree(array $comments, int $parentId = 0): array
{
$tree = [];
foreach ($comments as $comment) {
if ((int) $comment->comment_parent !== $parentId) {
continue;
}
$tree[] = [
'id' => (int) $comment->comment_ID,
'author' => $comment->comment_author,
'author_url' => $comment->comment_author_url,
'avatar_url' => get_avatar_url($comment, ['size' => 40]),
'date' => get_comment_date('', $comment),
'date_iso' => get_comment_date('c', $comment),
'content' => apply_filters('comment_text', $comment->comment_content, $comment),
'edit_url' => current_user_can('edit_comment', $comment->comment_ID)
? get_edit_comment_link($comment)
: '',
'reply_url' => get_comment_reply_link([
'depth' => 1,
'max_depth' => get_option('thread_comments_depth', 5),
], $comment),
'children' => $this->buildCommentTree($comments, (int) $comment->comment_ID),
];
}
return $tree;
}
/**
* Capture the WordPress comment form HTML.
*/
private function getCommentFormHtml(): string
{
if (! comments_open()) {
return '';
}
ob_start();
comment_form([
'title_reply' => __('Leave a Comment', 'wp-bootstrap'),
'title_reply_before' => '<h3 id="reply-title" class="comment-reply-title h5 mb-3">',
'title_reply_after' => '</h3>',
'class_form' => 'needs-validation',
'class_submit' => 'btn btn-primary',
'submit_button' => '<input name="%1$s" type="submit" id="%2$s" class="%3$s" value="%4$s" />',
'comment_field' => '<div class="mb-3"><label for="comment" class="form-label">' . __('Comment', 'wp-bootstrap') . '</label><textarea id="comment" name="comment" class="form-control" rows="5" required></textarea></div>',
]);
return ob_get_clean();
}
/**
* Get previous/next post navigation.
*/
private function getPostNavigation(): array
{
$prev = get_previous_post();
$next = get_next_post();
$navigation = [];
if ($prev) {
$navigation['previous'] = [
'title' => get_the_title($prev),
'url' => get_permalink($prev),
];
}
if ($next) {
$navigation['next'] = [
'title' => get_the_title($next),
'url' => get_permalink($next),
];
}
return $navigation;
}
/**
* Get recent posts for the "More posts" section.
*/
private function getMorePosts(int $count = 3): array
{
$currentId = get_the_ID();
$query = new \WP_Query([
'posts_per_page' => $count,
'post__not_in' => [$currentId],
'orderby' => 'date',
'order' => 'DESC',
]);
$posts = [];
if ($query->have_posts()) {
while ($query->have_posts()) {
$query->the_post();
$posts[] = [
'id' => get_the_ID(),
'title' => get_the_title(),
'url' => get_permalink(),
'date' => get_the_date(),
'date_iso' => get_the_date('c'),
'thumbnail' => get_the_post_thumbnail_url(null, 'medium_large') ?: '',
];
}
wp_reset_postdata();
}
return $posts;
}
/**
* Get sidebar widget data.
*/
private function getSidebarData(): array
{
return [
'recent_posts' => $this->getSidebarRecentPosts(),
'tags' => $this->getSidebarTags(),
];
}
/**
* Get recent posts for sidebar.
*/
private function getSidebarRecentPosts(int $count = 4): array
{
$query = new \WP_Query([
'posts_per_page' => $count,
'orderby' => 'date',
'order' => 'DESC',
]);
$posts = [];
if ($query->have_posts()) {
while ($query->have_posts()) {
$query->the_post();
$posts[] = [
'title' => get_the_title(),
'url' => get_permalink(),
'date' => get_the_date(),
];
}
wp_reset_postdata();
}
return $posts;
}
/**
* Get tags for sidebar tag cloud.
*/
private function getSidebarTags(int $count = 15): array
{
$tags = get_tags([
'number' => $count,
'orderby' => 'count',
'order' => 'DESC',
]);
if (! $tags || is_wp_error($tags)) {
return [];
}
$items = [];
foreach ($tags as $tag) {
$items[] = [
'name' => $tag->name,
'url' => get_tag_link($tag->term_id),
'count' => $tag->count,
];
}
return $items;
}
/**
* Get terms list for a taxonomy.
*/
private function getTermsList(string $taxonomy): array
{
$terms = get_the_terms(get_the_ID(), $taxonomy);
if (! $terms || is_wp_error($terms)) {
return [];
}
$list = [];
foreach ($terms as $term) {
$list[] = [
'name' => $term->name,
'url' => get_term_link($term),
];
}
return $list;
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* Bootstrap 5 Navigation Walker.
*
* Converts flat WordPress menu items into a nested tree
* suitable for rendering Bootstrap navbar dropdowns in Twig.
*
* @package WPBootstrap
* @since 0.1.1
*/
namespace WPBootstrap\Template;
class NavWalker
{
/**
* Build a nested menu tree from flat WordPress menu items.
*
* @param array $items Array of WP_Post menu item objects.
* @return array Nested array suitable for Twig iteration.
*/
public function buildTree(array $items): array
{
$tree = [];
$children = [];
foreach ($items as $item) {
$node = [
'id' => (int) $item->ID,
'title' => $item->title,
'url' => $item->url,
'target' => $item->target ?: '',
'classes' => implode(' ', array_filter($item->classes ?? [])),
'active' => $this->isActive($item),
'children' => [],
];
if ((int) $item->menu_item_parent === 0) {
$tree[$item->ID] = $node;
} else {
$children[(int) $item->menu_item_parent][] = $node;
}
}
// Assign children to their parent items.
foreach ($children as $parentId => $childItems) {
if (isset($tree[$parentId])) {
$tree[$parentId]['children'] = $childItems;
}
}
return array_values($tree);
}
/**
* Determine if a menu item is currently active.
*/
private function isActive(object $item): bool
{
$classes = $item->classes ?? [];
if (in_array('current-menu-item', $classes, true)) {
return true;
}
if (in_array('current-menu-ancestor', $classes, true)) {
return true;
}
if ($item->object === 'page' && is_page((int) $item->object_id)) {
return true;
}
if ($item->object === 'category' && is_category((int) $item->object_id)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* Template Controller.
*
* Intercepts frontend requests and renders Twig templates
* with proper Bootstrap 5 HTML instead of FSE block markup.
*
* @package WPBootstrap
* @since 0.1.1
*/
namespace WPBootstrap\Template;
use WPBootstrap\Twig\TwigService;
class TemplateController
{
private ContextBuilder $contextBuilder;
public function __construct()
{
$this->contextBuilder = new ContextBuilder();
add_action('template_redirect', [$this, 'render']);
}
/**
* Render the appropriate Twig template for the current request.
*/
public function render(): void
{
// Skip admin, REST API, and AJAX requests.
if (is_admin() || wp_doing_ajax()) {
return;
}
if (defined('REST_REQUEST') && REST_REQUEST) {
return;
}
$template = $this->resolveTemplate();
if (! $template) {
return;
}
try {
$context = $this->contextBuilder->build();
$twig = TwigService::getInstance();
echo $twig->render($template, $context);
exit;
} catch (\Throwable $e) {
// Log the error and fall back to FSE rendering.
error_log('WP Bootstrap Twig Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
if (defined('WP_DEBUG') && WP_DEBUG) {
wp_die(
'<h1>Template Rendering Error</h1>'
. '<p><strong>' . esc_html($e->getMessage()) . '</strong></p>'
. '<p>' . esc_html($e->getFile()) . ':' . esc_html($e->getLine()) . '</p>'
. '<pre>' . esc_html($e->getTraceAsString()) . '</pre>',
'Template Error',
['response' => 500]
);
}
}
}
/**
* Determine which Twig template to render based on WordPress conditionals.
*/
private function resolveTemplate(): ?string
{
if (is_404()) {
return 'pages/404.html.twig';
}
if (is_search()) {
return 'pages/search.html.twig';
}
if (is_singular('post')) {
return 'pages/single.html.twig';
}
if (is_page()) {
return 'pages/page.html.twig';
}
if (is_archive()) {
return 'pages/archive.html.twig';
}
if (is_home()) {
return 'pages/index.html.twig';
}
// Fallback.
return 'pages/index.html.twig';
}
}

View File

@@ -11,6 +11,7 @@ namespace WPBootstrap\Twig;
use Twig\Environment; use Twig\Environment;
use Twig\Loader\FilesystemLoader; use Twig\Loader\FilesystemLoader;
use Twig\TwigFunction; use Twig\TwigFunction;
use Twig\TwigFilter;
class TwigService class TwigService
{ {
@@ -30,6 +31,8 @@ class TwigService
]); ]);
$this->registerWordPressFunctions(); $this->registerWordPressFunctions();
$this->registerWordPressGlobals();
$this->registerFilters();
} }
public static function getInstance(): self public static function getInstance(): self
@@ -57,6 +60,7 @@ class TwigService
private function registerWordPressFunctions(): void private function registerWordPressFunctions(): void
{ {
// Translation functions.
$this->twig->addFunction(new TwigFunction('__', function (string $text, string $domain = 'wp-bootstrap'): string { $this->twig->addFunction(new TwigFunction('__', function (string $text, string $domain = 'wp-bootstrap'): string {
return __($text, $domain); return __($text, $domain);
})); }));
@@ -65,8 +69,88 @@ class TwigService
_e($text, $domain); _e($text, $domain);
})); }));
$this->twig->addFunction(new TwigFunction('_n', function (string $single, string $plural, int $number, string $domain = 'wp-bootstrap'): string {
return _n($single, $plural, $number, $domain);
}));
// Escaping functions.
$this->twig->addFunction(new TwigFunction('esc_html', 'esc_html')); $this->twig->addFunction(new TwigFunction('esc_html', 'esc_html'));
$this->twig->addFunction(new TwigFunction('esc_attr', 'esc_attr')); $this->twig->addFunction(new TwigFunction('esc_attr', 'esc_attr'));
$this->twig->addFunction(new TwigFunction('esc_url', 'esc_url')); $this->twig->addFunction(new TwigFunction('esc_url', 'esc_url'));
// WordPress head/footer output (captured via output buffering).
$this->twig->addFunction(new TwigFunction('wp_head', function (): string {
ob_start();
wp_head();
return ob_get_clean();
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('wp_footer', function (): string {
ob_start();
wp_footer();
return ob_get_clean();
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('wp_body_open', function (): string {
ob_start();
wp_body_open();
return ob_get_clean();
}, ['is_safe' => ['html']]));
// HTML attribute helpers.
$this->twig->addFunction(new TwigFunction('language_attributes', function (): string {
ob_start();
language_attributes();
return ob_get_clean();
}, ['is_safe' => ['html']]));
$this->twig->addFunction(new TwigFunction('body_class', function (string $extra = ''): string {
ob_start();
body_class($extra);
return ob_get_clean();
}, ['is_safe' => ['html']]));
// URL and info helpers.
$this->twig->addFunction(new TwigFunction('home_url', function (string $path = '/'): string {
return home_url($path);
}));
$this->twig->addFunction(new TwigFunction('get_template_directory_uri', function (): string {
return get_template_directory_uri();
}));
$this->twig->addFunction(new TwigFunction('get_bloginfo', function (string $show): string {
return get_bloginfo($show);
}));
$this->twig->addFunction(new TwigFunction('get_search_query', function (): string {
return get_search_query();
}));
// Content filtering.
$this->twig->addFunction(new TwigFunction('wp_kses_post', function (string $content): string {
return wp_kses_post($content);
}, ['is_safe' => ['html']]));
// Formatting.
$this->twig->addFunction(new TwigFunction('number_format_i18n', function (float $number, int $decimals = 0): string {
return number_format_i18n($number, $decimals);
}));
}
private function registerWordPressGlobals(): void
{
$this->twig->addGlobal('site_name', get_bloginfo('name'));
$this->twig->addGlobal('site_description', get_bloginfo('description'));
$this->twig->addGlobal('site_url', home_url('/'));
$this->twig->addGlobal('theme_uri', get_template_directory_uri());
$this->twig->addGlobal('charset', get_bloginfo('charset'));
$this->twig->addGlobal('current_year', date('Y'));
}
private function registerFilters(): void
{
$this->twig->addFilter(new TwigFilter('wpautop', 'wpautop', ['is_safe' => ['html']]));
$this->twig->addFilter(new TwigFilter('wp_kses_post', 'wp_kses_post', ['is_safe' => ['html']]));
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "wp-bootstrap", "name": "wp-bootstrap",
"version": "0.1.0", "version": "0.1.1",
"description": "WordPress Theme built with Bootstrap 5", "description": "WordPress Theme built with Bootstrap 5",
"author": "Marco Graetsch <magdev3.0@gmail.com>", "author": "Marco Graetsch <magdev3.0@gmail.com>",
"license": "GPL-2.0-or-later", "license": "GPL-2.0-or-later",

View File

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

View File

21
views/base.html.twig Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html {{ language_attributes() }}>
<head>
<meta charset="{{ charset }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{ wp_head() }}
</head>
<body {{ body_class() }}>
{{ wp_body_open() }}
{% include 'partials/header.html.twig' %}
<main id="main-content" class="{% block main_class %}py-4{% endblock %}">
{% block content %}{% endblock %}
</main>
{% include 'partials/footer.html.twig' %}
{{ wp_footer() }}
</body>
</html>

View File

@@ -0,0 +1,17 @@
<article class="card h-100">
{% if post.thumbnail %}
<a href="{{ post.url }}">
<img src="{{ post.thumbnail }}" class="card-img-top"
alt="{{ post.title|e('html_attr') }}"
style="aspect-ratio: 3/2; object-fit: cover;">
</a>
{% endif %}
<div class="card-body">
<h3 class="card-title h6">
<a href="{{ post.url }}" class="text-decoration-none text-body">{{ post.title }}</a>
</h3>
<p class="card-text text-body-secondary small">
<time datetime="{{ post.date_iso }}">{{ post.date }}</time>
</p>
</div>
</article>

View File

@@ -0,0 +1,21 @@
<article class="card mb-4 border-0 border-bottom rounded-0 pb-4">
{% if post.thumbnail %}
<a href="{{ post.url }}">
<img src="{{ post.thumbnail }}" class="card-img-top rounded" alt="{{ post.title|e('html_attr') }}">
</a>
{% endif %}
<div class="card-body px-0">
<h2 class="card-title h4">
<a href="{{ post.url }}" class="text-decoration-none text-body">{{ post.title }}</a>
</h2>
<div class="text-body-secondary small mb-2">
<time datetime="{{ post.date_iso }}">{{ post.date }}</time>
<span class="mx-1">&middot;</span>
<a href="{{ post.author.url }}" class="text-body-secondary text-decoration-none">{{ post.author.name }}</a>
</div>
<p class="card-text">{{ post.excerpt|raw }}</p>
<a href="{{ post.url }}" class="btn btn-outline-primary btn-sm">
{{ post.read_more }}
</a>
</div>
</article>

View File

@@ -0,0 +1,11 @@
{% if posts|length > 0 %}
{% for post in posts %}
{% include 'components/card-post.html.twig' with {'post': post} only %}
{% endfor %}
{% include 'partials/pagination.html.twig' %}
{% else %}
<div class="alert alert-secondary" role="alert">
{{ __('No posts were found.') }}
</div>
{% endif %}

16
views/pages/404.html.twig Normal file
View File

@@ -0,0 +1,16 @@
{% extends 'base.html.twig' %}
{% block content %}
<div class="container text-center py-5">
<h1 class="display-1 fw-bold text-body-secondary">404</h1>
<h2 class="mb-3">{{ __('Page not found') }}</h2>
<p class="lead text-body-secondary mb-4">
{{ __('The page you are looking for does not exist, or it has been moved. Please try searching using the form below.') }}
</p>
<div class="row justify-content-center">
<div class="col-md-6">
{% include 'partials/search-form.html.twig' %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'base.html.twig' %}
{% block content %}
<div class="container">
<h1 class="mb-2">{{ archive.title|raw }}</h1>
{% if archive.description %}
<div class="lead text-body-secondary mb-4">{{ archive.description|raw }}</div>
{% endif %}
{% include 'components/post-loop.html.twig' %}
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends 'base.html.twig' %}
{% block content %}
<div class="container">
<h1 class="mb-4">{{ __('Blog') }}</h1>
{% if layout == 'sidebar' %}
<div class="row">
<div class="col-lg-8">
{% include 'components/post-loop.html.twig' %}
</div>
<div class="col-lg-4">
{% include 'partials/sidebar.html.twig' %}
</div>
</div>
{% else %}
{% include 'components/post-loop.html.twig' %}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends 'base.html.twig' %}
{% block content %}
<div class="container">
<article class="py-4">
{% if post.thumbnail %}
<figure class="mb-4">
<img src="{{ post.thumbnail }}" class="img-fluid rounded"
alt="{{ post.title|e('html_attr') }}">
</figure>
{% endif %}
<h1>{{ post.title }}</h1>
<div class="post-content">
{{ post.content|raw }}
</div>
</article>
</div>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends 'base.html.twig' %}
{% block content %}
<div class="container">
<h1 class="mb-4">
{{ __('Search results for: %s')|format('<em>' ~ search_query ~ '</em>')|raw }}
</h1>
{% include 'partials/search-form.html.twig' %}
<div class="mt-4">
{% include 'components/post-loop.html.twig' %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends 'base.html.twig' %}
{% block content %}
<div class="container">
<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"
alt="{{ post.title|e('html_attr') }}"
style="aspect-ratio: 16/9; object-fit: cover; width: 100%;">
</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-3 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>
{% endblock %}

View File

@@ -0,0 +1,39 @@
<div class="comment d-flex gap-3 mb-4{% if depth > 0 %} ms-5{% endif %}" id="comment-{{ comment.id }}">
<div class="flex-shrink-0">
<img src="{{ comment.avatar_url }}" alt="{{ comment.author }}"
class="rounded-circle" width="40" height="40">
</div>
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
<strong class="small">
{% if comment.author_url %}
<a href="{{ comment.author_url }}" class="text-decoration-none text-body" rel="nofollow">
{{ comment.author }}
</a>
{% else %}
{{ comment.author }}
{% endif %}
</strong>
<time class="text-body-secondary small" datetime="{{ comment.date_iso }}">
{{ comment.date }}
</time>
{% if comment.edit_url %}
<a href="{{ comment.edit_url }}" class="text-body-secondary small">{{ __('Edit') }}</a>
{% endif %}
</div>
<div class="comment-content small">
{{ comment.content|raw }}
</div>
{% if comment.reply_url %}
<div class="mt-1">
{{ comment.reply_url|raw }}
</div>
{% endif %}
{% if comment.children|length > 0 %}
{% for child in comment.children %}
{% include 'partials/comment-item.html.twig' with {'comment': child, 'depth': depth + 1} only %}
{% endfor %}
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,19 @@
{% if comments is defined and (comments.is_open or comments.count > 0) %}
<section class="comments-section border-top pt-5 mt-5" id="comments">
<h2 class="h4 mb-4">{{ comments.title }}</h2>
{% if comments.list|length > 0 %}
<div class="comment-list mb-4">
{% for comment in comments.list %}
{% include 'partials/comment-item.html.twig' with {'comment': comment, 'depth': 0} only %}
{% endfor %}
</div>
{% endif %}
{% if comments.is_open %}
<div class="comment-form mt-4">
{{ comments.form|raw }}
</div>
{% endif %}
</section>
{% endif %}

View File

@@ -0,0 +1,21 @@
<button type="button" class="wp-bootstrap-dark-mode-toggle ms-2"
data-bs-theme-toggle
aria-label="{{ __('Switch to dark mode') }}"
data-label-dark="{{ __('Switch to dark mode') }}"
data-label-light="{{ __('Switch to light mode') }}"
aria-pressed="false">
<svg class="wp-bootstrap-sun-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none;" aria-hidden="true">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="wp-bootstrap-moon-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>

View File

@@ -0,0 +1,34 @@
<footer class="bg-body-tertiary mt-auto">
<div class="container py-5">
<div class="row">
<div class="col-md-6">
<h5 class="fw-bold">{{ site.name }}</h5>
<p class="text-body-secondary">{{ site.description }}</p>
</div>
<div class="col-md-6 text-md-end">
{% if footer_menu|length > 0 %}
<ul class="list-unstyled">
{% for item in footer_menu %}
<li>
<a href="{{ item.url }}" class="text-body-secondary text-decoration-none">
{{ item.title }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<hr>
<div class="row align-items-center">
<div class="col-md-6">
<p class="text-body-secondary small mb-0">&copy; {{ current_year }} {{ site.name }}</p>
</div>
<div class="col-md-6 text-md-end">
<p class="text-body-secondary small mb-0">
{{ __('Powered by %s')|format('<a href="https://wordpress.org" rel="nofollow" class="text-body-secondary">WordPress</a>')|raw }}
</p>
</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,56 @@
<header>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container">
<a class="navbar-brand fw-bold" href="{{ site.url }}">
{{ site.name }}
</a>
<button class="navbar-toggler" type="button"
data-bs-toggle="collapse" data-bs-target="#navbarMain"
aria-controls="navbarMain" aria-expanded="false"
aria-label="{{ __('Toggle navigation') }}">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarMain">
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
{% for item in menu %}
{% if item.children|length > 0 %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle{{ item.active ? ' active' : '' }}"
href="{{ item.url }}" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
{{ item.title }}
</a>
<ul class="dropdown-menu">
{% for child in item.children %}
<li>
<a class="dropdown-item{{ child.active ? ' active' : '' }}"
href="{{ child.url }}"
{% if child.target %}target="{{ child.target }}"{% endif %}>
{{ child.title }}
</a>
</li>
{% endfor %}
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link{{ item.active ? ' active' : '' }}"
href="{{ item.url }}"
{% if item.active %}aria-current="page"{% endif %}
{% if item.target %}target="{{ item.target }}"{% endif %}>
{{ item.title }}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
{% if dark_mode %}
{% include 'partials/dark-mode-toggle.html.twig' %}
{% endif %}
</div>
</div>
</nav>
</header>

View File

@@ -0,0 +1,11 @@
<div class="text-body-secondary small mb-3">
<time datetime="{{ post.date_iso }}">{{ post.date }}</time>
<span class="mx-1">&middot;</span>
<a href="{{ post.author.url }}" class="text-body-secondary text-decoration-none">{{ post.author.name }}</a>
{% if post.categories is defined and post.categories|length > 0 %}
<span class="mx-1">&middot;</span>
{% for cat in post.categories %}
<a href="{{ cat.url }}" class="text-body-secondary text-decoration-none">{{ cat.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% endif %}
</div>

View File

@@ -0,0 +1,26 @@
{% if pagination and pagination.pages is defined and pagination.pages|length > 1 %}
<nav aria-label="{{ __('Page navigation') }}" class="my-5">
<ul class="pagination justify-content-center">
<li class="page-item{{ pagination.prev_url is null ? ' disabled' : '' }}">
<a class="page-link" href="{{ pagination.prev_url ?? '#' }}"
{% if pagination.prev_url is null %}tabindex="-1" aria-disabled="true"{% endif %}>
{{ pagination.prev_text }}
</a>
</li>
{% for page in pagination.pages %}
<li class="page-item{{ page.is_current ? ' active' : '' }}">
<a class="page-link" href="{{ page.url }}"
{% if page.is_current %}aria-current="page"{% endif %}>
{{ page.number }}
</a>
</li>
{% endfor %}
<li class="page-item{{ pagination.next_url is null ? ' disabled' : '' }}">
<a class="page-link" href="{{ pagination.next_url ?? '#' }}"
{% if pagination.next_url is null %}tabindex="-1" aria-disabled="true"{% endif %}>
{{ pagination.next_text }}
</a>
</li>
</ul>
</nav>
{% endif %}

View File

@@ -0,0 +1,22 @@
{% if post_navigation is defined and post_navigation|length > 0 %}
<nav class="border-top border-bottom py-4 my-4" aria-label="{{ __('Post navigation') }}">
<div class="row">
<div class="col-6">
{% if post_navigation.previous is defined %}
<small class="text-body-secondary d-block mb-1">{{ __('Previous') }}</small>
<a href="{{ post_navigation.previous.url }}" class="text-decoration-none">
&larr; {{ post_navigation.previous.title }}
</a>
{% endif %}
</div>
<div class="col-6 text-end">
{% if post_navigation.next is defined %}
<small class="text-body-secondary d-block mb-1">{{ __('Next') }}</small>
<a href="{{ post_navigation.next.url }}" class="text-decoration-none">
{{ post_navigation.next.title }} &rarr;
</a>
{% endif %}
</div>
</div>
</nav>
{% endif %}

View File

@@ -0,0 +1,11 @@
<form role="search" method="get" action="{{ site.url }}" class="mb-4">
<div class="input-group">
<input type="search" class="form-control" name="s"
placeholder="{{ __('Search...') }}"
value="{{ search_query is defined ? search_query : '' }}"
aria-label="{{ __('Search') }}">
<button class="btn btn-primary" type="submit">
{{ __('Search') }}
</button>
</div>
</form>

View File

@@ -0,0 +1,44 @@
<aside>
{% if sidebar.recent_posts is defined and sidebar.recent_posts|length > 0 %}
<div class="mb-4">
<h3 class="h6 text-uppercase fw-semibold" style="letter-spacing: 1.6px">
{{ __('Recent Posts') }}
</h3>
<ul class="list-unstyled">
{% for post in sidebar.recent_posts %}
<li class="mb-2">
<a href="{{ post.url }}" class="text-decoration-none">{{ post.title }}</a>
<br>
<small class="text-body-secondary">{{ post.date }}</small>
</li>
{% endfor %}
</ul>
</div>
<hr>
{% endif %}
<div class="mb-4">
<h3 class="h6 text-uppercase fw-semibold" style="letter-spacing: 1.6px">
{{ __('Search') }}
</h3>
{% include 'partials/search-form.html.twig' %}
</div>
<hr>
{% if sidebar.tags is defined and sidebar.tags|length > 0 %}
<div class="mb-4">
<h3 class="h6 text-uppercase fw-semibold" style="letter-spacing: 1.6px">
{{ __('Tags') }}
</h3>
<div>
{% for tag in sidebar.tags %}
<a href="{{ tag.url }}" class="badge bg-secondary text-decoration-none me-1 mb-1">
{{ tag.name }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
</aside>