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:
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user