You've already forked wp-bootstrap
v0.1.1 - Bootstrap frontend rendering via Twig templates
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:
28
CHANGELOG.md
28
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
28
CLAUDE.md
28
CLAUDE.md
@@ -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
17
PLAN.md
@@ -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
106
README.md
@@ -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>
|
||||||
|
|||||||
@@ -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' );
|
||||||
|
|||||||
471
inc/Template/ContextBuilder.php
Normal file
471
inc/Template/ContextBuilder.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
inc/Template/NavWalker.php
Normal file
80
inc/Template/NavWalker.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
inc/Template/TemplateController.php
Normal file
99
inc/Template/TemplateController.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
21
views/base.html.twig
Normal file
21
views/base.html.twig
Normal 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>
|
||||||
17
views/components/card-post-grid.html.twig
Normal file
17
views/components/card-post-grid.html.twig
Normal 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>
|
||||||
21
views/components/card-post.html.twig
Normal file
21
views/components/card-post.html.twig
Normal 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">·</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>
|
||||||
11
views/components/post-loop.html.twig
Normal file
11
views/components/post-loop.html.twig
Normal 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
16
views/pages/404.html.twig
Normal 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 %}
|
||||||
12
views/pages/archive.html.twig
Normal file
12
views/pages/archive.html.twig
Normal 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 %}
|
||||||
20
views/pages/index.html.twig
Normal file
20
views/pages/index.html.twig
Normal 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 %}
|
||||||
20
views/pages/page.html.twig
Normal file
20
views/pages/page.html.twig
Normal 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 %}
|
||||||
15
views/pages/search.html.twig
Normal file
15
views/pages/search.html.twig
Normal 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 %}
|
||||||
50
views/pages/single.html.twig
Normal file
50
views/pages/single.html.twig
Normal 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 %}
|
||||||
39
views/partials/comment-item.html.twig
Normal file
39
views/partials/comment-item.html.twig
Normal 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>
|
||||||
19
views/partials/comments.html.twig
Normal file
19
views/partials/comments.html.twig
Normal 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 %}
|
||||||
21
views/partials/dark-mode-toggle.html.twig
Normal file
21
views/partials/dark-mode-toggle.html.twig
Normal 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>
|
||||||
34
views/partials/footer.html.twig
Normal file
34
views/partials/footer.html.twig
Normal 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">© {{ 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>
|
||||||
56
views/partials/header.html.twig
Normal file
56
views/partials/header.html.twig
Normal 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>
|
||||||
11
views/partials/meta.html.twig
Normal file
11
views/partials/meta.html.twig
Normal 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">·</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">·</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>
|
||||||
26
views/partials/pagination.html.twig
Normal file
26
views/partials/pagination.html.twig
Normal 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 %}
|
||||||
22
views/partials/post-navigation.html.twig
Normal file
22
views/partials/post-navigation.html.twig
Normal 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">
|
||||||
|
← {{ 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 }} →
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
11
views/partials/search-form.html.twig
Normal file
11
views/partials/search-form.html.twig
Normal 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>
|
||||||
44
views/partials/sidebar.html.twig
Normal file
44
views/partials/sidebar.html.twig
Normal 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>
|
||||||
Reference in New Issue
Block a user