diff --git a/CHANGELOG.md b/CHANGELOG.md index edd8c0e..bd97757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ 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 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 9d9af40..5e1ff4b 100644 --- a/CLAUDE.md +++ b/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. -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 @@ -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. - **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. +- **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 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) **Completed:** Full v0.1.0 milestone implementation. diff --git a/PLAN.md b/PLAN.md index 97aeb2c..f7c6049 100644 --- a/PLAN.md +++ b/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: -- **Templates** (`templates/`): HTML files with WordPress block markup -- **Template Parts** (`parts/`): Reusable header/footer components +- **Templates** (`templates/`): HTML files with WordPress block markup (Site Editor) +- **Template Parts** (`parts/`): Reusable header/footer components (Site Editor) - **Patterns** (`patterns/`): PHP files with block markup and i18n support - **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 @@ -52,6 +54,17 @@ node_modules/bootstrap/dist/js/ → copyfiles → assets/js/bootstrap.bundle.min - [x] Sidebar template part - [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 - [ ] Full Design Editor compatibility diff --git a/README.md b/README.md index 343509a..16b0a39 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,24 @@ # 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 -- Full Site Editing (FSE) support -- Bootstrap 5 CSS and JavaScript -- Responsive design -- Dark mode ready (Bootstrap 5.3 dark mode variables) -- Twig 3.0 template engine -- WordPress i18n support -- Gitea CI/CD automated releases +- **Bootstrap 5 Frontend** -- Proper Bootstrap 5 HTML (navbar, cards, pagination, grid) rendered via Twig templates +- **Dark Mode** -- Toggle with localStorage persistence and `prefers-color-scheme` support +- **Full Site Editing** -- Compatible with the WordPress Site Editor for admin editing +- **Block Patterns** -- 16 patterns across 7 categories (hero, features, CTA, testimonials, pricing, contact, text) +- **Block Styles** -- 17 custom styles mapping Bootstrap components to WordPress blocks +- **Style Variations** -- 4 color schemes: Ocean, Forest, Sunset, Midnight +- **Responsive** -- Mobile-first design with Bootstrap's responsive grid +- **Translation Ready** -- Full i18n support with `en_US` and `de_CH` translations ## Requirements - WordPress 6.7 or higher - PHP 8.3 or higher +- Composer +- Node.js 20+ ## Installation @@ -35,6 +38,8 @@ npm install npm run build ``` +Activate the theme in **Appearance > Themes** in the WordPress admin. + ## Development ### Prerequisites @@ -48,33 +53,86 @@ npm run build | 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 scss` | Compile SCSS only | -| `npm run postcss` | Minify CSS with Autoprefixer | -| `composer install` | Install PHP dependencies | +| `npm run postcss` | Minify CSS with Autoprefixer and cssnano | +| `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 ```txt wp-bootstrap/ -├── assets/ Compiled CSS, JS, and images -├── inc/ PHP classes (PSR-4 autoloaded) -├── languages/ Translation files (.pot, .po) -├── parts/ FSE template parts (header, footer) -├── patterns/ Block patterns -├── src/scss/ SCSS source files -├── templates/ FSE page templates -├── views/ Twig templates -├── functions.php Theme bootstrap -├── style.css Theme metadata -└── theme.json Design tokens and settings ++-- assets/ Compiled CSS, JS, fonts ++-- inc/ +| +-- Template/ TemplateController, ContextBuilder, NavWalker +| +-- Twig/ TwigService singleton ++-- languages/ Translation files (.pot, .po) ++-- patterns/ Block patterns (PHP) ++-- parts/ FSE template parts (header, footer, sidebar) ++-- src/ +| +-- js/ Source JavaScript +| +-- scss/ Source SCSS ++-- 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 -GPL-2.0-or-later +GPL-2.0-or-later. See . ## Author -Marco Graetsch - [src.bundespruefstelle.ch/magdev](https://src.bundespruefstelle.ch/magdev) +Marco Graetsch - diff --git a/functions.php b/functions.php index 39d0a8f..e7f64c6 100644 --- a/functions.php +++ b/functions.php @@ -34,6 +34,12 @@ if ( ! function_exists( 'wp_bootstrap_setup' ) ) : // Add editor styles. 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; add_action( 'after_setup_theme', 'wp_bootstrap_setup' ); @@ -315,3 +321,62 @@ if ( ! function_exists( 'wp_bootstrap_init_twig' ) ) : } endif; 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'] = '
' + . '' + . '' + . '
'; + + $fields['email'] = '
' + . '' + . '' + . '
'; + + $fields['url'] = '
' + . '' + . '' + . '
'; + + if ( isset( $fields['cookies'] ) ) { + $fields['cookies'] = '
' + . '' + . '
'; + } + + return $fields; + } +endif; +add_filter( 'comment_form_default_fields', 'wp_bootstrap_comment_form_fields' ); diff --git a/inc/Template/ContextBuilder.php b/inc/Template/ContextBuilder.php new file mode 100644 index 0000000..47d35d2 --- /dev/null +++ b/inc/Template/ContextBuilder.php @@ -0,0 +1,471 @@ +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' => '

', + 'title_reply_after' => '

', + 'class_form' => 'needs-validation', + 'class_submit' => 'btn btn-primary', + 'submit_button' => '', + 'comment_field' => '
', + ]); + 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; + } +} diff --git a/inc/Template/NavWalker.php b/inc/Template/NavWalker.php new file mode 100644 index 0000000..1384471 --- /dev/null +++ b/inc/Template/NavWalker.php @@ -0,0 +1,80 @@ + (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; + } +} diff --git a/inc/Template/TemplateController.php b/inc/Template/TemplateController.php new file mode 100644 index 0000000..791209e --- /dev/null +++ b/inc/Template/TemplateController.php @@ -0,0 +1,99 @@ +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( + '

Template Rendering Error

' + . '

' . esc_html($e->getMessage()) . '

' + . '

' . esc_html($e->getFile()) . ':' . esc_html($e->getLine()) . '

' + . '
' . esc_html($e->getTraceAsString()) . '
', + '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'; + } +} diff --git a/inc/Twig/TwigService.php b/inc/Twig/TwigService.php index 70d2dc9..ad247ce 100644 --- a/inc/Twig/TwigService.php +++ b/inc/Twig/TwigService.php @@ -11,6 +11,7 @@ namespace WPBootstrap\Twig; use Twig\Environment; use Twig\Loader\FilesystemLoader; use Twig\TwigFunction; +use Twig\TwigFilter; class TwigService { @@ -30,6 +31,8 @@ class TwigService ]); $this->registerWordPressFunctions(); + $this->registerWordPressGlobals(); + $this->registerFilters(); } public static function getInstance(): self @@ -57,6 +60,7 @@ class TwigService private function registerWordPressFunctions(): void { + // Translation functions. $this->twig->addFunction(new TwigFunction('__', function (string $text, string $domain = 'wp-bootstrap'): string { return __($text, $domain); })); @@ -65,8 +69,88 @@ class TwigService _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_attr', 'esc_attr')); $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']])); } } diff --git a/package.json b/package.json index 4678f07..017b1a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wp-bootstrap", - "version": "0.1.0", + "version": "0.1.1", "description": "WordPress Theme built with Bootstrap 5", "author": "Marco Graetsch ", "license": "GPL-2.0-or-later", diff --git a/style.css b/style.css index 50737f8..b7f0e8e 100644 --- a/style.css +++ b/style.css @@ -7,7 +7,7 @@ Description: A modern WordPress Block Theme built from scratch with Bootstrap 5. Requires at least: 6.7 Tested up to: 6.7 Requires PHP: 8.3 -Version: 0.1.0 +Version: 0.1.1 License: GNU General Public License v2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html Text Domain: wp-bootstrap diff --git a/views/.gitkeep b/views/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/views/base.html.twig b/views/base.html.twig new file mode 100644 index 0000000..b573913 --- /dev/null +++ b/views/base.html.twig @@ -0,0 +1,21 @@ + + + + + + {{ wp_head() }} + + + {{ wp_body_open() }} + + {% include 'partials/header.html.twig' %} + +
+ {% block content %}{% endblock %} +
+ + {% include 'partials/footer.html.twig' %} + + {{ wp_footer() }} + + diff --git a/views/components/card-post-grid.html.twig b/views/components/card-post-grid.html.twig new file mode 100644 index 0000000..284fea0 --- /dev/null +++ b/views/components/card-post-grid.html.twig @@ -0,0 +1,17 @@ + diff --git a/views/components/card-post.html.twig b/views/components/card-post.html.twig new file mode 100644 index 0000000..d6edac6 --- /dev/null +++ b/views/components/card-post.html.twig @@ -0,0 +1,21 @@ + diff --git a/views/components/post-loop.html.twig b/views/components/post-loop.html.twig new file mode 100644 index 0000000..3ea3f67 --- /dev/null +++ b/views/components/post-loop.html.twig @@ -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 %} + +{% endif %} diff --git a/views/pages/404.html.twig b/views/pages/404.html.twig new file mode 100644 index 0000000..6ea6595 --- /dev/null +++ b/views/pages/404.html.twig @@ -0,0 +1,16 @@ +{% extends 'base.html.twig' %} + +{% block content %} +
+

404

+

{{ __('Page not found') }}

+

+ {{ __('The page you are looking for does not exist, or it has been moved. Please try searching using the form below.') }} +

+
+
+ {% include 'partials/search-form.html.twig' %} +
+
+
+{% endblock %} diff --git a/views/pages/archive.html.twig b/views/pages/archive.html.twig new file mode 100644 index 0000000..5569492 --- /dev/null +++ b/views/pages/archive.html.twig @@ -0,0 +1,12 @@ +{% extends 'base.html.twig' %} + +{% block content %} +
+

{{ archive.title|raw }}

+ {% if archive.description %} +
{{ archive.description|raw }}
+ {% endif %} + + {% include 'components/post-loop.html.twig' %} +
+{% endblock %} diff --git a/views/pages/index.html.twig b/views/pages/index.html.twig new file mode 100644 index 0000000..b0bf0c3 --- /dev/null +++ b/views/pages/index.html.twig @@ -0,0 +1,20 @@ +{% extends 'base.html.twig' %} + +{% block content %} +
+

{{ __('Blog') }}

+ + {% if layout == 'sidebar' %} +
+
+ {% include 'components/post-loop.html.twig' %} +
+
+ {% include 'partials/sidebar.html.twig' %} +
+
+ {% else %} + {% include 'components/post-loop.html.twig' %} + {% endif %} +
+{% endblock %} diff --git a/views/pages/page.html.twig b/views/pages/page.html.twig new file mode 100644 index 0000000..329d016 --- /dev/null +++ b/views/pages/page.html.twig @@ -0,0 +1,20 @@ +{% extends 'base.html.twig' %} + +{% block content %} +
+
+ {% if post.thumbnail %} +
+ {{ post.title|e('html_attr') }} +
+ {% endif %} + +

{{ post.title }}

+ +
+ {{ post.content|raw }} +
+
+
+{% endblock %} diff --git a/views/pages/search.html.twig b/views/pages/search.html.twig new file mode 100644 index 0000000..5814be8 --- /dev/null +++ b/views/pages/search.html.twig @@ -0,0 +1,15 @@ +{% extends 'base.html.twig' %} + +{% block content %} +
+

+ {{ __('Search results for: %s')|format('' ~ search_query ~ '')|raw }} +

+ + {% include 'partials/search-form.html.twig' %} + +
+ {% include 'components/post-loop.html.twig' %} +
+
+{% endblock %} diff --git a/views/pages/single.html.twig b/views/pages/single.html.twig new file mode 100644 index 0000000..d066cb9 --- /dev/null +++ b/views/pages/single.html.twig @@ -0,0 +1,50 @@ +{% extends 'base.html.twig' %} + +{% block content %} +
+
+
+

{{ post.title }}

+ {% include 'partials/meta.html.twig' %} +
+ + {% if post.thumbnail %} +
+ {{ post.title|e('html_attr') }} +
+ {% endif %} + +
+ {{ post.content|raw }} +
+ + {% if post.tags|length > 0 %} +
+ {% for tag in post.tags %} + + {{ tag.name }} + + {% endfor %} +
+ {% endif %} + + {% include 'partials/post-navigation.html.twig' %} + {% include 'partials/comments.html.twig' %} +
+ + {% if more_posts is defined and more_posts|length > 0 %} +
+

{{ __('More posts') }}

+
+ {% for post in more_posts %} +
+ {% include 'components/card-post-grid.html.twig' with {'post': post} only %} +
+ {% endfor %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/views/partials/comment-item.html.twig b/views/partials/comment-item.html.twig new file mode 100644 index 0000000..e55955f --- /dev/null +++ b/views/partials/comment-item.html.twig @@ -0,0 +1,39 @@ +
+
+ {{ comment.author }} +
+
+
+ + {% if comment.author_url %} + + {{ comment.author }} + + {% else %} + {{ comment.author }} + {% endif %} + + + {% if comment.edit_url %} + {{ __('Edit') }} + {% endif %} +
+
+ {{ comment.content|raw }} +
+ {% if comment.reply_url %} +
+ {{ comment.reply_url|raw }} +
+ {% 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 %} +
+
diff --git a/views/partials/comments.html.twig b/views/partials/comments.html.twig new file mode 100644 index 0000000..7393586 --- /dev/null +++ b/views/partials/comments.html.twig @@ -0,0 +1,19 @@ +{% if comments is defined and (comments.is_open or comments.count > 0) %} +
+

{{ comments.title }}

+ + {% if comments.list|length > 0 %} +
+ {% for comment in comments.list %} + {% include 'partials/comment-item.html.twig' with {'comment': comment, 'depth': 0} only %} + {% endfor %} +
+ {% endif %} + + {% if comments.is_open %} +
+ {{ comments.form|raw }} +
+ {% endif %} +
+{% endif %} diff --git a/views/partials/dark-mode-toggle.html.twig b/views/partials/dark-mode-toggle.html.twig new file mode 100644 index 0000000..b6321ea --- /dev/null +++ b/views/partials/dark-mode-toggle.html.twig @@ -0,0 +1,21 @@ + diff --git a/views/partials/footer.html.twig b/views/partials/footer.html.twig new file mode 100644 index 0000000..477d26e --- /dev/null +++ b/views/partials/footer.html.twig @@ -0,0 +1,34 @@ +
+
+
+
+
{{ site.name }}
+

{{ site.description }}

+
+
+ {% if footer_menu|length > 0 %} + + {% endif %} +
+
+
+
+
+

© {{ current_year }} {{ site.name }}

+
+
+

+ {{ __('Powered by %s')|format('WordPress')|raw }} +

+
+
+
+
diff --git a/views/partials/header.html.twig b/views/partials/header.html.twig new file mode 100644 index 0000000..35f90ea --- /dev/null +++ b/views/partials/header.html.twig @@ -0,0 +1,56 @@ +
+ +
diff --git a/views/partials/meta.html.twig b/views/partials/meta.html.twig new file mode 100644 index 0000000..fb2d351 --- /dev/null +++ b/views/partials/meta.html.twig @@ -0,0 +1,11 @@ +
+ + · + {{ post.author.name }} + {% if post.categories is defined and post.categories|length > 0 %} + · + {% for cat in post.categories %} + {{ cat.name }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% endif %} +
diff --git a/views/partials/pagination.html.twig b/views/partials/pagination.html.twig new file mode 100644 index 0000000..f28c9fe --- /dev/null +++ b/views/partials/pagination.html.twig @@ -0,0 +1,26 @@ +{% if pagination and pagination.pages is defined and pagination.pages|length > 1 %} + +{% endif %} diff --git a/views/partials/post-navigation.html.twig b/views/partials/post-navigation.html.twig new file mode 100644 index 0000000..e03ce37 --- /dev/null +++ b/views/partials/post-navigation.html.twig @@ -0,0 +1,22 @@ +{% if post_navigation is defined and post_navigation|length > 0 %} + +{% endif %} diff --git a/views/partials/search-form.html.twig b/views/partials/search-form.html.twig new file mode 100644 index 0000000..ccc7b02 --- /dev/null +++ b/views/partials/search-form.html.twig @@ -0,0 +1,11 @@ + diff --git a/views/partials/sidebar.html.twig b/views/partials/sidebar.html.twig new file mode 100644 index 0000000..0f0f68c --- /dev/null +++ b/views/partials/sidebar.html.twig @@ -0,0 +1,44 @@ +