You've already forked wp-bootstrap
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c8526d2a5 | |||
| 02689f687f | |||
| 17728e81d9 | |||
| ea2ccef5de | |||
| e607382e11 | |||
| 3165e60639 | |||
| 9904bf508a |
@@ -24,10 +24,31 @@ jobs:
|
||||
run: |
|
||||
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 php -l
|
||||
|
||||
test:
|
||||
name: PHPUnit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, xml, dom
|
||||
tools: composer:v2
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: composer exec -- phpunit
|
||||
|
||||
build-release:
|
||||
name: Build Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
needs: [test]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -54,16 +75,19 @@ jobs:
|
||||
- name: Install Node dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build assets
|
||||
run: npm run build
|
||||
|
||||
- name: Validate composer.json
|
||||
run: composer validate --no-check-lock --no-check-all
|
||||
|
||||
- name: Install Composer dependencies (production)
|
||||
- name: Install Composer dependencies (with dev for prebuild tests)
|
||||
run: |
|
||||
composer config platform.php 8.3.0
|
||||
composer install --no-dev --optimize-autoloader --no-interaction
|
||||
composer install --no-interaction
|
||||
|
||||
- name: Build assets
|
||||
run: npm run build
|
||||
|
||||
- name: Reinstall Composer dependencies (production only)
|
||||
run: composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
- name: Install gettext
|
||||
run: apt-get update && apt-get install -y gettext
|
||||
@@ -120,6 +144,9 @@ jobs:
|
||||
-x "${THEME_NAME}/*.log" \
|
||||
-x "${THEME_NAME}/*.po~" \
|
||||
-x "${THEME_NAME}/*.bak" \
|
||||
-x "${THEME_NAME}/tests/*" \
|
||||
-x "${THEME_NAME}/phpunit.xml.dist" \
|
||||
-x "${THEME_NAME}/.phpunit.cache/*" \
|
||||
-x "${THEME_NAME}/views/.gitkeep" \
|
||||
-x "${THEME_NAME}/assets/images/.gitkeep" \
|
||||
-x "*.DS_Store"
|
||||
@@ -187,6 +214,14 @@ jobs:
|
||||
echo "src/ excluded: OK"
|
||||
fi
|
||||
|
||||
# Verify tests excluded
|
||||
if unzip -l "releases/${THEME_NAME}-${VERSION}.zip" | grep -q "${THEME_NAME}/tests/"; then
|
||||
echo "WARNING: tests/ directory should be excluded"
|
||||
exit 1
|
||||
else
|
||||
echo "tests/ excluded: OK"
|
||||
fi
|
||||
|
||||
- name: Extract changelog for release notes
|
||||
id: changelog
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,5 +28,8 @@ npm-debug.log
|
||||
# Claude local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
# PHPUnit cache
|
||||
.phpunit.cache/
|
||||
|
||||
# Build artifacts (releases directory)
|
||||
releases/
|
||||
|
||||
67
CHANGELOG.md
67
CHANGELOG.md
@@ -2,6 +2,73 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.1.3] - 2026-03-07
|
||||
|
||||
### Security
|
||||
|
||||
- **Template output escaping**: Added `|esc_url` filter to all unescaped URL outputs across 8 Twig template partials — `header.html.twig`, `header-offcanvas.html.twig`, `header-transparent.html.twig`, `header-centered.html.twig`, `footer.html.twig`, `footer-columns.html.twig`, `search-form.html.twig`, `comment-item.html.twig`. Covers `site.url`, `item.url`, `child.url`, `user.account_url`, `comment.author_url`, and `comment.edit_url`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Twig escape filters** (`TwigService.php`): Registered `esc_html`, `esc_attr`, and `esc_url` as Twig filters with `['is_safe' => ['html']]` to prevent double-encoding. Complements existing `wpautop` and `wp_kses_post` filters.
|
||||
|
||||
## [1.1.2] - 2026-03-01
|
||||
|
||||
### Security
|
||||
|
||||
- **WidgetRenderer regex hardening**: Combined two separate `preg_replace` calls for h2→h4 heading downgrade into a single regex that only matches `<h2>` elements with the `wp-block-heading` class. The previous approach replaced all `</h2>` tags unconditionally, risking mismatched tags if a widget contained non-block h2 elements.
|
||||
|
||||
### Performance
|
||||
|
||||
- **O(n) comment tree building** (`ContextBuilder`): Replaced O(n²) recursive scan with a parent-indexed lookup map built in a single pass. Each recursion level now iterates only direct children instead of all comments.
|
||||
- **Consolidated sidebar queries** (`ContextBuilder`): Merged three separate sidebar detection branches (`is_home`, `is_page`+sidebar, `is_singular` post) into a single boolean check with one `getSidebarData()` call, eliminating up to 2 redundant calls per request.
|
||||
- **Transient caching for sidebar data** (`ContextBuilder`): `getSidebarRecentPosts()` and `getSidebarTags()` results cached in WordPress transients (1 hour TTL). Invalidation hooks on `save_post` (recent posts) and `create/edit/delete_post_tag` (tags).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hex-to-RGB consolidation** (`functions.php`): `wp_bootstrap_hex_to_rgb()` now delegates to `wp_bootstrap_hex_to_rgb_array()` instead of duplicating hex parsing logic. Added `ctype_xdigit()` validation and return type hints to all color utility functions.
|
||||
|
||||
## [1.1.1] - 2026-02-28
|
||||
|
||||
### Added
|
||||
|
||||
- **PHPUnit test suite**: 64 unit tests covering `BlockRenderer`, `WidgetRenderer`, `NavWalker`, and `TemplateController` classes with 107 assertions. Uses PHPUnit 11 and Brain\Monkey for WordPress function mocking.
|
||||
- **Test infrastructure**: `WP_HTML_Tag_Processor` functional stub using DOMDocument for testing block renderer HTML manipulation outside WordPress. Empty stubs for `WP_Block` and `WP_Widget` type hints.
|
||||
- **Build pipeline integration**: Tests run automatically before every `npm run build` via `prebuild` hook (`composer exec -- phpunit`).
|
||||
- **CI test job**: New PHPUnit test step in Gitea CI workflow between lint and build-release. Tests must pass before release packages are built.
|
||||
- **Release package exclusions**: `tests/`, `phpunit.xml.dist`, and `.phpunit.cache/` excluded from release ZIP packages with verification step.
|
||||
|
||||
## [1.1.0] - 2026-02-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Block Renderer** (`inc/Block/BlockRenderer.php`): New class that injects Bootstrap 5 classes into WordPress core block HTML output on the frontend via per-block `render_block_{$name}` filters. Handles 8 block types:
|
||||
- `core/table` — `.table` on `<table>`, `.table-striped` when stripes style is active
|
||||
- `core/button` — `.btn` + `.btn-{variant}` or `.btn-outline-{variant}` mapped from WP preset color slugs
|
||||
- `core/buttons` — `.d-flex .flex-wrap .gap-2` on button group wrapper
|
||||
- `core/image` — `.img-fluid` on `<img>` for responsive images
|
||||
- `core/search` — `.input-group` on inner wrapper, `.form-control` on input, `.btn .btn-primary` on button
|
||||
- `core/quote` — `.blockquote` on `<blockquote>`, `.blockquote-footer` on `<cite>`
|
||||
- `core/pullquote` — Same blockquote treatment inside `<figure>`
|
||||
- `core/list` — `.list-group` + `.list-group-item` when `is-style-list-group` block style is selected
|
||||
- **Widget Renderer** (`inc/Block/WidgetRenderer.php`): New class that transforms sidebar widgets into Bootstrap 5 card components via `dynamic_sidebar_params` and `widget_block_content` filters. Wraps each widget in a `.card > .card-body` structure with `.card-title` headings. Downgrades block widget `<h2>` headings to `<h4>` for proper sidebar visual hierarchy.
|
||||
- **Widget SCSS** (`src/scss/_widgets.scss`): New stylesheet for sidebar widget Bootstrap styling — list-group-style list items with border separators, flush-to-card-edge list positioning, Bootstrap form-control styling for select dropdowns, search form input-group layout, tag cloud with pill badges, and secondary-color post dates.
|
||||
- **List Group block style**: New "List Group" style registered for `core/list` blocks — applies Bootstrap `.list-group` and `.list-group-item` classes when selected in the editor.
|
||||
- **Single post sidebar template** (`views/pages/single-sidebar.html.twig`): New two-column layout for blog posts with `col-lg-8` content area and `col-lg-4` sidebar. Includes all single post features (meta, thumbnail, tags, post navigation, comments, more posts). "More posts" section uses `row-cols-md-2` to fit the narrower column.
|
||||
- **Extensibility**: `wp_bootstrap_block_renderer_blocks` filter allows child themes to add/remove block handler mappings.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Post template default** (`inc/Template/TemplateController.php`): Blog posts now render with the sidebar layout by default (`single-sidebar.html.twig`). Posts assigned the "Full Width" template use `single.html.twig` instead. Template selection uses `get_page_template_slug()` with a `match` expression.
|
||||
- **Sidebar data for posts** (`inc/Template/ContextBuilder.php`): Posts always receive sidebar data (recent posts, tags, widgets) regardless of template selection, ensuring the sidebar partial always has data available.
|
||||
- **Widget SCSS import** (`src/scss/style.scss`): Added `_widgets` partial import between Bootstrap Icons and custom styles.
|
||||
|
||||
## [1.0.12] - 2026-02-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Admin bar offcanvas padding on desktop** (`functions.php`): Scoped the admin bar offcanvas padding fix to mobile viewports only (`max-width: 991.98px`) so the extra padding does not appear on wide screens where the offcanvas renders inline as a regular navbar.
|
||||
|
||||
## [1.0.11] - 2026-02-28
|
||||
|
||||
### Changed
|
||||
|
||||
127
CLAUDE.md
127
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.
|
||||
|
||||
Current version is **v1.0.11**. See `PLAN.md` for details.
|
||||
Current version is **v1.1.2**. See `PLAN.md` for details.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -234,6 +234,131 @@ Build steps (in order):
|
||||
|
||||
## Session History
|
||||
|
||||
### Session 21 — v1.1.2 Security Audit & Performance Fixes (2026-03-01)
|
||||
|
||||
**Completed:** Cross-theme security audit with 12 findings, all fixed. Covers WidgetRenderer regex hardening, ContextBuilder performance (O(n) comment tree, sidebar query consolidation, transient caching), and hex-to-RGB code consolidation.
|
||||
|
||||
**What was changed:**
|
||||
|
||||
- **WidgetRenderer regex fix** (`inc/Block/WidgetRenderer.php`): Combined two `preg_replace` calls into single regex matching `<h2>` with `wp-block-heading` class, preventing mismatched tags.
|
||||
- **O(n) comment tree** (`inc/Template/ContextBuilder.php`): Parent-indexed lookup map replaces full-scan recursion. Each level now iterates only direct children.
|
||||
- **Sidebar query consolidation** (`inc/Template/ContextBuilder.php`): Three separate sidebar detection branches merged into single boolean + one `getSidebarData()` call.
|
||||
- **Transient caching** (`inc/Template/ContextBuilder.php` + `functions.php`): `getSidebarRecentPosts()` and `getSidebarTags()` cached in 1-hour transients with hook-based invalidation (`save_post`, `create/edit/delete_post_tag`).
|
||||
- **Hex-to-RGB consolidation** (`functions.php`): Eliminated duplicate hex parsing. Added `ctype_xdigit()` validation and type hints to all color utility functions.
|
||||
|
||||
**Files modified:**
|
||||
|
||||
- `inc/Block/WidgetRenderer.php` — single regex for h2→h4
|
||||
- `inc/Template/ContextBuilder.php` — O(n) tree, sidebar consolidation, transient caching
|
||||
- `functions.php` — hex-to-RGB consolidation, type hints, transient invalidation hooks
|
||||
- `style.css` — version bump to 1.1.2
|
||||
- `CHANGELOG.md`, `CLAUDE.md` — documentation
|
||||
|
||||
### Session 20 — v1.1.1 PHPUnit Test Suite (2026-02-28)
|
||||
|
||||
**Completed:** PHPUnit test suite with 64 unit tests and 107 assertions, CI integration, and build pipeline gating.
|
||||
|
||||
**What was built:**
|
||||
|
||||
- **PHPUnit 11 + Brain\Monkey 2.7**: Added as `require-dev` with PSR-4 `autoload-dev` mapping (`WPBootstrap\Tests\` → `tests/`).
|
||||
- **WP_HTML_Tag_Processor stub** (`tests/Stubs/WpHtmlTagProcessor.php`): Functional DOMDocument-based replacement supporting `next_tag()` (by tag name or class_name), `add_class()` (idempotent), and `get_updated_html()`. Uses full HTML document wrapping for reliable body extraction.
|
||||
- **BlockRendererTest** (28 tests): All 8 render methods — table classes, striped tables, button variants (default/bg/outline/gradient/unknown), button group flex, img-fluid, search input-group, blockquote+cite, pullquote, list-group, empty content returns.
|
||||
- **WidgetRendererTest** (9 tests): Card structure, title heading, widget ID, type class extraction, generic filtering, h2→h4 regex, multiple h2 elements, empty content.
|
||||
- **NavWalkerTest** (14 tests): Tree building (empty, single, flat, nested, multi-parent, orphans), node structure, classes, index reset, active detection (current-menu-item, ancestor, is_page, is_category, inactive).
|
||||
- **TemplateControllerTest** (12 tests): Template resolution via ReflectionMethod for all page types (404, search, post default/full-width, page default/landing/full-width/hero/sidebar, archive, home, fallback).
|
||||
- **Build pipeline**: `npm run test` and `prebuild` hook gate `npm run build` on passing tests.
|
||||
- **CI workflow**: New `test` job between `lint` and `build-release` with PHP 8.3 + Composer.
|
||||
- **Release exclusions**: `tests/`, `phpunit.xml.dist`, `.phpunit.cache/*` excluded from ZIP with verification step.
|
||||
|
||||
**Architecture decisions:**
|
||||
|
||||
- **Brain\Monkey over full WordPress bootstrap**: WordPress function mocking via `Functions\when()->justReturn()` and `Functions\when()->alias()` enables fast, isolated unit tests without a running WordPress installation.
|
||||
- **DOMDocument stub over regex**: `WP_HTML_Tag_Processor` stub uses `DOMDocument`/`DOMXPath` for reliable HTML parsing. Full document wrapping (`<!DOCTYPE><html><body>...`) required because `LIBXML_HTML_NOIMPLIED` prevents `<body>` creation in PHP 8.4, breaking `getElementsByTagName('body')`.
|
||||
- **`is_admin()=true` constructor bypass**: BlockRenderer/WidgetRenderer constructors register WordPress filters. Mocking `is_admin()` to return true causes early exit, enabling direct method testing.
|
||||
- **ReflectionMethod for private methods**: `TemplateController::resolveTemplate()` is private. Testing via reflection avoids refactoring production code for testability.
|
||||
- **ContextBuilder and TwigService skipped**: Too many WordPress dependencies for practical unit testing — better suited for integration tests.
|
||||
|
||||
**Key findings:**
|
||||
|
||||
- `LIBXML_HTML_NOIMPLIED` with `DOMDocument::loadHTML()` on PHP 8.4 does not create a `<body>` element, causing `getElementsByTagName('body')->item(0)` to return null. Solution: wrap input in full HTML document structure.
|
||||
- Brain\Monkey's `Functions\when()->alias()` supports argument-dependent returns for functions like `is_singular()` that behave differently based on post type argument.
|
||||
- `spl_object_id()` used in the stub's visited-node tracking enables sequential `next_tag()` advancement matching WordPress's forward-only API.
|
||||
|
||||
**Files created:**
|
||||
|
||||
- `phpunit.xml.dist` — PHPUnit configuration
|
||||
- `tests/bootstrap.php` — Autoloader + stub loading
|
||||
- `tests/Stubs/WpHtmlTagProcessor.php` — Functional DOMDocument-based stub
|
||||
- `tests/Stubs/WpBlock.php` — Empty class stub
|
||||
- `tests/Stubs/WpWidget.php` — Empty class stub
|
||||
- `tests/Unit/Block/BlockRendererTest.php` — 28 tests
|
||||
- `tests/Unit/Block/WidgetRendererTest.php` — 9 tests
|
||||
- `tests/Unit/Template/NavWalkerTest.php` — 14 tests
|
||||
- `tests/Unit/Template/TemplateControllerTest.php` — 12 tests
|
||||
|
||||
**Files modified:**
|
||||
|
||||
- `composer.json` — `require-dev`, `autoload-dev`
|
||||
- `package.json` — `test`, `prebuild` scripts
|
||||
- `.gitea/workflows/release.yml` — test job, exclusions, verification
|
||||
- `.gitignore` — `.phpunit.cache/`
|
||||
- `style.css` — version bump to 1.1.1
|
||||
- `CHANGELOG.md`, `README.md`, `CLAUDE.md` — documentation
|
||||
|
||||
### Session 19 — v1.1.0 Block Renderer, Widget Renderer & Sidebar Post Layout (2026-02-28)
|
||||
|
||||
**Completed:** Bootstrap 5 class injection for core blocks, sidebar widget card wrappers, widget SCSS styling, and sidebar-default post template.
|
||||
|
||||
**What was built:**
|
||||
|
||||
- **BlockRenderer** (`inc/Block/BlockRenderer.php`): Per-block `render_block_{$name}` filters inject Bootstrap classes into 8 core block types using `WP_HTML_Tag_Processor`. Supports button color mapping (WP preset slugs → Bootstrap btn-{variant}), striped tables, responsive images, input-group search forms, blockquote styling, and list-group block style. Extensible via `wp_bootstrap_block_renderer_blocks` filter.
|
||||
- **WidgetRenderer** (`inc/Block/WidgetRenderer.php`): `dynamic_sidebar_params` filter wraps sidebar widgets in Bootstrap `.card > .card-body` structure. `widget_block_content` filter downgrades block widget `<h2 class="wp-block-heading">` to `<h4>` via preg_replace. Widget ID and type-specific classes extracted from the already-processed `before_widget` string.
|
||||
- **Widget SCSS** (`src/scss/_widgets.scss`): Comprehensive widget styling — list items with border separators, flush-to-card-edge positioning (negating card-body padding), form-control selects, input-group search forms, pill-badge tag cloud, and secondary-color post dates. Handles both legacy and block widget nesting (`.wp-block-group > ul`).
|
||||
- **List Group block style** (`functions.php`): `register_block_style('core/list', ...)` for the `is-style-list-group` style option in the editor.
|
||||
- **Single post sidebar template** (`views/pages/single-sidebar.html.twig`): Two-column Bootstrap layout (8/4 split) with all single post features. "More posts" section uses `row-cols-md-2` for the narrower column.
|
||||
- **Post template selection** (`inc/Template/TemplateController.php`): Posts default to sidebar layout; `page-full-width` template slug maps to full-width.
|
||||
- **Sidebar data always loaded for posts** (`inc/Template/ContextBuilder.php`): `getSidebarData()` called unconditionally for `is_singular('post')`.
|
||||
|
||||
**Architecture decisions:**
|
||||
|
||||
- **Per-block filters over generic `render_block`**: `render_block_{$blockName}` only fires for the targeted block type, avoiding callback overhead on every block render. More maintainable — each handler is scoped to one block type.
|
||||
- **`WP_HTML_Tag_Processor` over regex**: WordPress 6.7+ class provides safe, forward-only HTML manipulation. `add_class()` is idempotent (no duplicate classes). Handles malformed HTML gracefully.
|
||||
- **Card-body with card-title, not card-header**: WordPress omits `before_title`/`after_title` entirely when a widget has no title. Using `card-header` for the title would leave an empty `<div class="card-header"></div>` for titleless widgets — broken HTML. Card-body with card-title inside works correctly in both cases.
|
||||
- **`dynamic_sidebar_params` + regex extraction**: WordPress runs `sprintf` on `before_widget` BEFORE the `dynamic_sidebar_params` filter fires, so placeholder strings (`%1$s`, `%2$s`) are already replaced. Widget ID comes from `$params[0]['widget_id']`, type classes extracted via regex from the processed HTML.
|
||||
- **Sidebar default for posts**: Blog posts are content-centric and benefit from sidebar context (recent posts, search, tags). Full-width is opt-in via "Full Width" template assignment.
|
||||
|
||||
**Key findings:**
|
||||
|
||||
- `WP_Block $instance` type hint is too strict for manual `apply_filters()` calls — WordPress passes `null` when filters are invoked outside the block rendering pipeline. Use `?WP_Block $instance = null`.
|
||||
- Block widgets nest content inside `.wp-block-group` wrappers. CSS selectors like `.card-body > ul` won't match — need `.card-body > .wp-block-group > ul` for flush list positioning.
|
||||
- `widget_block_content` filter (WordPress 5.8+) fires for block-based widgets only, allowing inner HTML modification without affecting legacy widgets.
|
||||
- WordPress search block uses `.wp-block-search__inside-wrapper` as the input+button container — adding `.input-group` to this element creates a proper Bootstrap input-group.
|
||||
- `@extend .btn; @extend .btn-primary` in SCSS works for search submit buttons because Bootstrap is imported before the widgets partial in the SCSS cascade.
|
||||
|
||||
**Files created:**
|
||||
|
||||
- `inc/Block/BlockRenderer.php` — 8 block handlers
|
||||
- `inc/Block/WidgetRenderer.php` — card wrapper + heading downgrade
|
||||
- `src/scss/_widgets.scss` — widget Bootstrap styling
|
||||
- `views/pages/single-sidebar.html.twig` — two-column post template
|
||||
|
||||
**Files modified:**
|
||||
|
||||
- `functions.php` — init hooks for both renderers, list-group block style
|
||||
- `src/scss/style.scss` — widgets import
|
||||
- `inc/Template/TemplateController.php` — post template selection logic
|
||||
- `inc/Template/ContextBuilder.php` — always load sidebar for posts
|
||||
- `style.css` — version bump to 1.1.0
|
||||
- `CHANGELOG.md`, `README.md`, `CLAUDE.md` — documentation
|
||||
|
||||
### Session 18 — v1.0.12 Admin Bar Offcanvas Fix (2026-02-28)
|
||||
|
||||
**Completed:** Scoped admin bar offcanvas padding to mobile viewports only.
|
||||
|
||||
**What was fixed:**
|
||||
|
||||
- `functions.php`: Added `@media (max-width: 991.98px)` wrapper to the admin bar offcanvas padding CSS so the extra padding does not appear on desktop where the offcanvas renders inline as a regular navbar.
|
||||
|
||||
### Session 17 — v1.0.11 Offcanvas Navigation & User Context (2026-02-28)
|
||||
|
||||
**Completed:** Switched mobile navigation from Bootstrap collapse to offcanvas, added logged-in user context to the header, and fixed admin bar overlap.
|
||||
|
||||
58
README.md
58
README.md
@@ -10,8 +10,10 @@ A modern WordPress Block Theme built from scratch with Bootstrap 5. Features res
|
||||
- **Style Variations** -- 15 color schemes (7 light, 7 dark, plus default) with live Design Editor customization
|
||||
- **Block Patterns** -- 41 patterns across 11 categories (hero, features, CTA, testimonials, pricing, contact, text, layout, components, navigation, pages)
|
||||
- **Bootstrap Icons** -- 2,000+ icons available via CSS classes (`bi bi-*`)
|
||||
- **Block Styles** -- 17 custom styles mapping Bootstrap components to WordPress blocks
|
||||
- **Custom Templates** -- Landing (no header/footer), full-width, hero, sidebar page templates
|
||||
- **Block Renderer** -- Automatic Bootstrap 5 class injection on 8 core block types (table, button, image, search, quote, pullquote, list) via `render_block` filters
|
||||
- **Widget Renderer** -- Sidebar widgets wrapped in Bootstrap cards with proper heading hierarchy
|
||||
- **Block Styles** -- 18 custom styles mapping Bootstrap components to WordPress blocks (including List Group)
|
||||
- **Custom Templates** -- Landing (no header/footer), full-width, hero, sidebar page templates; blog posts default to sidebar layout
|
||||
- **Header/Footer Variations** -- Default, centered, transparent headers; default, minimal, multi-column footers
|
||||
- **Navigation Styles** -- Dark navbar, offcanvas mobile navigation
|
||||
- **Widget Area** -- Sidebar widget area manageable via WordPress admin, with built-in fallback
|
||||
@@ -60,20 +62,41 @@ Activate the theme in **Appearance > Themes** in the WordPress admin.
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `npm run build` | Full production build (copy JS, compile SCSS, minify CSS) |
|
||||
| `npm run build` | Full production build (runs tests, copies JS, compiles SCSS, minifies CSS) |
|
||||
| `npm run test` | Run PHPUnit test suite |
|
||||
| `npm run dev` | Watch SCSS files and recompile on changes |
|
||||
| `npm run scss` | Compile SCSS only |
|
||||
| `npm run postcss` | Minify CSS with Autoprefixer and cssnano |
|
||||
| `composer install` | Install PHP dependencies (Twig) |
|
||||
|
||||
### Testing
|
||||
|
||||
The theme includes a PHPUnit test suite with 64 unit tests and 107 assertions covering the core PHP classes:
|
||||
|
||||
- **BlockRenderer** -- All 8 render methods (table, button, buttons, image, search, quote, pullquote, list)
|
||||
- **WidgetRenderer** -- Card wrapping, heading downgrade, class extraction
|
||||
- **NavWalker** -- Tree building, active item detection, orphan handling
|
||||
- **TemplateController** -- Template resolution for all page types
|
||||
|
||||
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) for WordPress function mocking and a functional `WP_HTML_Tag_Processor` stub.
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
composer exec -- phpunit
|
||||
|
||||
# Tests also run automatically before every build
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 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. `copy:icons` -- Copy Bootstrap Icons font files (`.woff`, `.woff2`) to `assets/fonts/`
|
||||
4. `scss` -- Compile SCSS (`src/scss/`) to CSS (`assets/css/`)
|
||||
5. `scss:rtl` -- Compile RTL stylesheet (`assets/css/rtl.css`)
|
||||
6. `postcss` -- Autoprefixer + cssnano minification to `assets/css/style.min.css`
|
||||
1. `test` -- Run PHPUnit test suite (automatic via `prebuild` hook)
|
||||
2. `copy:js` -- Copy Bootstrap JS bundle from `node_modules` to `assets/js/`
|
||||
3. `copy:theme-js` -- Copy theme JS (dark-mode.js) from `src/js/` to `assets/js/`
|
||||
4. `copy:icons` -- Copy Bootstrap Icons font files (`.woff`, `.woff2`) to `assets/fonts/`
|
||||
5. `scss` -- Compile SCSS (`src/scss/`) to CSS (`assets/css/`)
|
||||
6. `scss:rtl` -- Compile RTL stylesheet (`assets/css/rtl.css`)
|
||||
7. `postcss` -- Autoprefixer + cssnano minification to `assets/css/style.min.css`
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -98,6 +121,8 @@ WordPress style variation colors are bridged to Bootstrap CSS custom properties
|
||||
| `TemplateController` | Hooks `template_redirect`, resolves and renders Twig templates |
|
||||
| `ContextBuilder` | Gathers WordPress data (posts, menus, pagination, comments, sidebar) |
|
||||
| `NavWalker` | Converts flat menu items to nested tree for Bootstrap dropdowns |
|
||||
| `BlockRenderer` | Injects Bootstrap 5 classes into core block HTML via `render_block` filters |
|
||||
| `WidgetRenderer` | Wraps sidebar widgets in Bootstrap card components |
|
||||
|
||||
### Navigation Menus
|
||||
|
||||
@@ -110,7 +135,15 @@ If no menu is assigned, the primary location falls back to listing published pag
|
||||
|
||||
### Widget Areas
|
||||
|
||||
The theme registers a **Sidebar** widget area. When widgets are assigned via **Appearance > Widgets**, they replace the default sidebar content. When no widgets are assigned, the sidebar displays recent posts, a search form, and a tag cloud.
|
||||
The theme registers a **Sidebar** widget area. When widgets are assigned via **Appearance > Widgets**, they replace the default sidebar content. When no widgets are assigned, the sidebar displays recent posts, a search form, and a tag cloud. All sidebar widgets are automatically wrapped in Bootstrap card components with consistent heading styles.
|
||||
|
||||
### Block Renderer
|
||||
|
||||
The `BlockRenderer` class hooks per-block `render_block_{$name}` filters (more performant than a single `render_block` filter) and uses WordPress's `WP_HTML_Tag_Processor` for safe, idempotent class injection. Only active on the frontend — admin, REST API, and AJAX requests are skipped. Child themes can modify the block-to-handler map via the `wp_bootstrap_block_renderer_blocks` filter.
|
||||
|
||||
### Widget Renderer
|
||||
|
||||
The `WidgetRenderer` class transforms sidebar widget output into Bootstrap card components. It hooks `dynamic_sidebar_params` for wrapper HTML and `widget_block_content` for inner content adjustments (heading level downgrade from `<h2>` to `<h4>`). The card structure uses `card-body` with `card-title` inside, ensuring valid HTML whether or not a widget outputs a title.
|
||||
|
||||
### Project Structure
|
||||
|
||||
@@ -118,6 +151,7 @@ The theme registers a **Sidebar** widget area. When widgets are assigned via **A
|
||||
wp-bootstrap/
|
||||
+-- assets/ Compiled CSS, JS, fonts
|
||||
+-- inc/
|
||||
| +-- Block/ BlockRenderer, WidgetRenderer
|
||||
| +-- Template/ TemplateController, ContextBuilder, NavWalker
|
||||
| +-- Twig/ TwigService singleton
|
||||
+-- languages/ Translation files (.pot, .po)
|
||||
@@ -127,6 +161,9 @@ wp-bootstrap/
|
||||
| +-- js/ Source JavaScript
|
||||
| +-- scss/ Source SCSS
|
||||
+-- styles/ Style variations (JSON)
|
||||
+-- tests/
|
||||
| +-- Stubs/ WordPress class stubs for testing
|
||||
| +-- Unit/ PHPUnit test cases
|
||||
+-- templates/ FSE templates (HTML)
|
||||
+-- views/ Twig templates (Bootstrap 5 HTML)
|
||||
| +-- base.html.twig
|
||||
@@ -145,6 +182,7 @@ wp-bootstrap/
|
||||
- **Bootstrap 5.3+** CSS & JS (served locally)
|
||||
- **Dart Sass** for SCSS compilation
|
||||
- **PostCSS** with Autoprefixer and cssnano
|
||||
- **PHPUnit 11** with Brain\Monkey for WordPress function mocking
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -2443,7 +2443,7 @@ textarea.form-control-lg {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-check[disabled] + .btn, .btn-check:disabled + .btn {
|
||||
.btn-check[disabled] + .btn, .widget .search-form .btn-check[disabled] + .search-submit, .btn-check:disabled + .btn, .widget .search-form .btn-check:disabled + .search-submit {
|
||||
pointer-events: none;
|
||||
filter: none;
|
||||
opacity: 0.65;
|
||||
@@ -2642,11 +2642,11 @@ textarea.form-control-lg {
|
||||
.input-group > .form-floating:focus-within {
|
||||
z-index: 5;
|
||||
}
|
||||
.input-group .btn {
|
||||
.input-group .btn, .input-group .widget .search-form .search-submit, .widget .search-form .input-group .search-submit {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.input-group .btn:focus {
|
||||
.input-group .btn:focus, .input-group .widget .search-form .search-submit:focus, .widget .search-form .input-group .search-submit:focus {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@@ -2668,7 +2668,8 @@ textarea.form-control-lg {
|
||||
.input-group-lg > .form-control,
|
||||
.input-group-lg > .form-select,
|
||||
.input-group-lg > .input-group-text,
|
||||
.input-group-lg > .btn {
|
||||
.input-group-lg > .btn,
|
||||
.widget .search-form .input-group-lg > .search-submit {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1.25rem;
|
||||
border-radius: var(--bs-border-radius-lg);
|
||||
@@ -2677,7 +2678,8 @@ textarea.form-control-lg {
|
||||
.input-group-sm > .form-control,
|
||||
.input-group-sm > .form-select,
|
||||
.input-group-sm > .input-group-text,
|
||||
.input-group-sm > .btn {
|
||||
.input-group-sm > .btn,
|
||||
.widget .search-form .input-group-sm > .search-submit {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
@@ -2893,7 +2895,7 @@ textarea.form-control-lg {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
.btn, .widget .search-form .search-submit {
|
||||
--bs-btn-padding-x: 0.75rem;
|
||||
--bs-btn-padding-y: 0.375rem;
|
||||
--bs-btn-font-family: ;
|
||||
@@ -2927,44 +2929,44 @@ textarea.form-control-lg {
|
||||
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn {
|
||||
.btn, .widget .search-form .search-submit {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.btn:hover {
|
||||
.btn:hover, .widget .search-form .search-submit:hover {
|
||||
color: var(--bs-btn-hover-color);
|
||||
background-color: var(--bs-btn-hover-bg);
|
||||
border-color: var(--bs-btn-hover-border-color);
|
||||
}
|
||||
.btn-check + .btn:hover {
|
||||
.btn-check + .btn:hover, .widget .search-form .btn-check + .search-submit:hover {
|
||||
color: var(--bs-btn-color);
|
||||
background-color: var(--bs-btn-bg);
|
||||
border-color: var(--bs-btn-border-color);
|
||||
}
|
||||
.btn:focus-visible {
|
||||
.btn:focus-visible, .widget .search-form .search-submit:focus-visible {
|
||||
color: var(--bs-btn-hover-color);
|
||||
background-color: var(--bs-btn-hover-bg);
|
||||
border-color: var(--bs-btn-hover-border-color);
|
||||
outline: 0;
|
||||
box-shadow: var(--bs-btn-focus-box-shadow);
|
||||
}
|
||||
.btn-check:focus-visible + .btn {
|
||||
.btn-check:focus-visible + .btn, .widget .search-form .btn-check:focus-visible + .search-submit {
|
||||
border-color: var(--bs-btn-hover-border-color);
|
||||
outline: 0;
|
||||
box-shadow: var(--bs-btn-focus-box-shadow);
|
||||
}
|
||||
.btn-check:checked + .btn, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show {
|
||||
.btn-check:checked + .btn, .widget .search-form .btn-check:checked + .search-submit, :not(.btn-check) + .btn:active, .widget .search-form :not(.btn-check) + .search-submit:active, .btn:first-child:active, .widget .search-form .search-submit:first-child:active, .btn.active, .widget .search-form .active.search-submit, .btn.show, .widget .search-form .show.search-submit {
|
||||
color: var(--bs-btn-active-color);
|
||||
background-color: var(--bs-btn-active-bg);
|
||||
border-color: var(--bs-btn-active-border-color);
|
||||
}
|
||||
.btn-check:checked + .btn:focus-visible, :not(.btn-check) + .btn:active:focus-visible, .btn:first-child:active:focus-visible, .btn.active:focus-visible, .btn.show:focus-visible {
|
||||
.btn-check:checked + .btn:focus-visible, .widget .search-form .btn-check:checked + .search-submit:focus-visible, :not(.btn-check) + .btn:active:focus-visible, .widget .search-form :not(.btn-check) + .search-submit:active:focus-visible, .btn:first-child:active:focus-visible, .widget .search-form .search-submit:first-child:active:focus-visible, .btn.active:focus-visible, .widget .search-form .active.search-submit:focus-visible, .btn.show:focus-visible, .widget .search-form .show.search-submit:focus-visible {
|
||||
box-shadow: var(--bs-btn-focus-box-shadow);
|
||||
}
|
||||
.btn-check:checked:focus-visible + .btn {
|
||||
.btn-check:checked:focus-visible + .btn, .widget .search-form .btn-check:checked:focus-visible + .search-submit {
|
||||
box-shadow: var(--bs-btn-focus-box-shadow);
|
||||
}
|
||||
.btn:disabled, .btn.disabled, fieldset:disabled .btn {
|
||||
.btn:disabled, .widget .search-form .search-submit:disabled, .btn.disabled, .widget .search-form .disabled.search-submit, fieldset:disabled .btn, fieldset:disabled .widget .search-form .search-submit, .widget .search-form fieldset:disabled .search-submit {
|
||||
color: var(--bs-btn-disabled-color);
|
||||
pointer-events: none;
|
||||
background-color: var(--bs-btn-disabled-bg);
|
||||
@@ -2972,7 +2974,7 @@ textarea.form-control-lg {
|
||||
opacity: var(--bs-btn-disabled-opacity);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
.btn-primary, .widget .search-form .search-submit {
|
||||
--bs-btn-color: #FFFFFF;
|
||||
--bs-btn-bg: #0d6efd;
|
||||
--bs-btn-border-color: #0d6efd;
|
||||
@@ -3266,14 +3268,14 @@ textarea.form-control-lg {
|
||||
color: var(--bs-btn-hover-color);
|
||||
}
|
||||
|
||||
.btn-lg, .btn-group-lg > .btn {
|
||||
.btn-lg, .btn-group-lg > .btn, .widget .search-form .btn-group-lg > .search-submit {
|
||||
--bs-btn-padding-y: 0.5rem;
|
||||
--bs-btn-padding-x: 1rem;
|
||||
--bs-btn-font-size: 1.25rem;
|
||||
--bs-btn-border-radius: var(--bs-border-radius-lg);
|
||||
}
|
||||
|
||||
.btn-sm, .btn-group-sm > .btn {
|
||||
.btn-sm, .btn-group-sm > .btn, .widget .search-form .btn-group-sm > .search-submit {
|
||||
--bs-btn-padding-y: 0.25rem;
|
||||
--bs-btn-padding-x: 0.5rem;
|
||||
--bs-btn-font-size: 0.875rem;
|
||||
@@ -3640,23 +3642,35 @@ textarea.form-control-lg {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.btn-group > .btn,
|
||||
.btn-group-vertical > .btn {
|
||||
.btn-group > .btn, .widget .search-form .btn-group > .search-submit,
|
||||
.btn-group-vertical > .btn,
|
||||
.widget .search-form .btn-group-vertical > .search-submit {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.btn-group > .btn-check:checked + .btn,
|
||||
.btn-group > .btn-check:checked + .btn, .widget .search-form .btn-group > .btn-check:checked + .search-submit,
|
||||
.btn-group > .btn-check:focus + .btn,
|
||||
.widget .search-form .btn-group > .btn-check:focus + .search-submit,
|
||||
.btn-group > .btn:hover,
|
||||
.widget .search-form .btn-group > .search-submit:hover,
|
||||
.btn-group > .btn:focus,
|
||||
.widget .search-form .btn-group > .search-submit:focus,
|
||||
.btn-group > .btn:active,
|
||||
.widget .search-form .btn-group > .search-submit:active,
|
||||
.btn-group > .btn.active,
|
||||
.widget .search-form .btn-group > .active.search-submit,
|
||||
.btn-group-vertical > .btn-check:checked + .btn,
|
||||
.widget .search-form .btn-group-vertical > .btn-check:checked + .search-submit,
|
||||
.btn-group-vertical > .btn-check:focus + .btn,
|
||||
.widget .search-form .btn-group-vertical > .btn-check:focus + .search-submit,
|
||||
.btn-group-vertical > .btn:hover,
|
||||
.widget .search-form .btn-group-vertical > .search-submit:hover,
|
||||
.btn-group-vertical > .btn:focus,
|
||||
.widget .search-form .btn-group-vertical > .search-submit:focus,
|
||||
.btn-group-vertical > .btn:active,
|
||||
.btn-group-vertical > .btn.active {
|
||||
.widget .search-form .btn-group-vertical > .search-submit:active,
|
||||
.btn-group-vertical > .btn.active,
|
||||
.widget .search-form .btn-group-vertical > .active.search-submit {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -3672,19 +3686,23 @@ textarea.form-control-lg {
|
||||
.btn-group {
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
.btn-group > :not(.btn-check:first-child) + .btn,
|
||||
.btn-group > :not(.btn-check:first-child) + .btn, .widget .search-form .btn-group > :not(.btn-check:first-child) + .search-submit,
|
||||
.btn-group > .btn-group:not(:first-child) {
|
||||
margin-left: calc(-1 * var(--bs-border-width));
|
||||
}
|
||||
.btn-group > .btn:not(:last-child):not(.dropdown-toggle),
|
||||
.btn-group > .btn:not(:last-child):not(.dropdown-toggle), .widget .search-form .btn-group > .search-submit:not(:last-child):not(.dropdown-toggle),
|
||||
.btn-group > .btn.dropdown-toggle-split:first-child,
|
||||
.btn-group > .btn-group:not(:last-child) > .btn {
|
||||
.widget .search-form .btn-group > .dropdown-toggle-split.search-submit:first-child,
|
||||
.btn-group > .btn-group:not(:last-child) > .btn,
|
||||
.widget .search-form .btn-group > .btn-group:not(:last-child) > .search-submit {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.btn-group > .btn:nth-child(n+3),
|
||||
.btn-group > .btn:nth-child(n+3), .widget .search-form .btn-group > .search-submit:nth-child(n+3),
|
||||
.btn-group > :not(.btn-check) + .btn,
|
||||
.btn-group > .btn-group:not(:first-child) > .btn {
|
||||
.widget .search-form .btn-group > :not(.btn-check) + .search-submit,
|
||||
.btn-group > .btn-group:not(:first-child) > .btn,
|
||||
.widget .search-form .btn-group > .btn-group:not(:first-child) > .search-submit {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
@@ -3700,12 +3718,12 @@ textarea.form-control-lg {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split {
|
||||
.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split, .widget .search-form .btn-group-sm > .search-submit + .dropdown-toggle-split {
|
||||
padding-right: 0.375rem;
|
||||
padding-left: 0.375rem;
|
||||
}
|
||||
|
||||
.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split {
|
||||
.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split, .widget .search-form .btn-group-lg > .search-submit + .dropdown-toggle-split {
|
||||
padding-right: 0.75rem;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
@@ -3715,22 +3733,25 @@ textarea.form-control-lg {
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-group-vertical > .btn,
|
||||
.btn-group-vertical > .btn, .widget .search-form .btn-group-vertical > .search-submit,
|
||||
.btn-group-vertical > .btn-group {
|
||||
width: 100%;
|
||||
}
|
||||
.btn-group-vertical > .btn:not(:first-child),
|
||||
.btn-group-vertical > .btn:not(:first-child), .widget .search-form .btn-group-vertical > .search-submit:not(:first-child),
|
||||
.btn-group-vertical > .btn-group:not(:first-child) {
|
||||
margin-top: calc(-1 * var(--bs-border-width));
|
||||
}
|
||||
.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),
|
||||
.btn-group-vertical > .btn-group:not(:last-child) > .btn {
|
||||
.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), .widget .search-form .btn-group-vertical > .search-submit:not(:last-child):not(.dropdown-toggle),
|
||||
.btn-group-vertical > .btn-group:not(:last-child) > .btn,
|
||||
.widget .search-form .btn-group-vertical > .btn-group:not(:last-child) > .search-submit {
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.btn-group-vertical > .btn:nth-child(n+3),
|
||||
.btn-group-vertical > .btn:nth-child(n+3), .widget .search-form .btn-group-vertical > .search-submit:nth-child(n+3),
|
||||
.btn-group-vertical > :not(.btn-check) + .btn,
|
||||
.btn-group-vertical > .btn-group:not(:first-child) > .btn {
|
||||
.widget .search-form .btn-group-vertical > :not(.btn-check) + .search-submit,
|
||||
.btn-group-vertical > .btn-group:not(:first-child) > .btn,
|
||||
.widget .search-form .btn-group-vertical > .btn-group:not(:first-child) > .search-submit {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
@@ -4769,7 +4790,7 @@ textarea.form-control-lg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn .badge {
|
||||
.btn .badge, .widget .search-form .search-submit .badge {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
@@ -6718,7 +6739,7 @@ textarea.form-control-lg {
|
||||
background-color: currentcolor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.placeholder.btn::before {
|
||||
.placeholder.btn::before, .widget .search-form .placeholder.search-submit::before {
|
||||
display: inline-block;
|
||||
content: "";
|
||||
}
|
||||
@@ -20227,6 +20248,127 @@ h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6, blockquote, caption, figca
|
||||
content: "爛";
|
||||
}
|
||||
|
||||
.widget .card-title,
|
||||
.widget .wp-block-heading {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.widget ul,
|
||||
.widget ol {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.widget ul li,
|
||||
.widget ol li {
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: var(--bs-border-width) solid var(--bs-border-color);
|
||||
}
|
||||
.widget ul li:last-child,
|
||||
.widget ol li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.widget ul li a,
|
||||
.widget ol li a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.widget ul li a:hover,
|
||||
.widget ol li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.widget .card-body > ul,
|
||||
.widget .card-body > ol,
|
||||
.widget .card-body > nav > ul,
|
||||
.widget .card-body > .wp-block-group > ul,
|
||||
.widget .card-body > .wp-block-group > ol {
|
||||
margin: 0 calc(-1 * var(--bs-card-spacer-x)) calc(-1 * var(--bs-card-spacer-y));
|
||||
}
|
||||
.widget .card-body > ul li:first-child,
|
||||
.widget .card-body > ol li:first-child,
|
||||
.widget .card-body > nav > ul li:first-child,
|
||||
.widget .card-body > .wp-block-group > ul li:first-child,
|
||||
.widget .card-body > .wp-block-group > ol li:first-child {
|
||||
border-top: var(--bs-border-width) solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.widget select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
appearance: auto;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
.widget select:focus {
|
||||
border-color: rgb(134, 182.5, 254);
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.widget .search-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.widget .search-form .search-field {
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
.widget .search-form .search-field:focus {
|
||||
border-color: rgb(134, 182.5, 254);
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
.widget .wp-block-search .wp-block-search__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.widget .tagcloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.widget .tagcloud a {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem !important;
|
||||
line-height: 1.5;
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius-pill);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
|
||||
}
|
||||
.widget .tagcloud a:hover {
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.widget .post-date {
|
||||
display: block;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.widget .recentcomments {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.wp-bootstrap-dark-mode-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
File diff suppressed because one or more lines are too long
4
assets/css/style.min.css
vendored
4
assets/css/style.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -14,11 +14,20 @@
|
||||
"php": ">=8.3",
|
||||
"twig/twig": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"brain/monkey": "^2.6",
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"WPBootstrap\\": "inc/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"WPBootstrap\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"sort-packages": true
|
||||
|
||||
2038
composer.lock
generated
2038
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,7 @@ if ( ! function_exists( 'wp_bootstrap_enqueue_scripts' ) ) :
|
||||
// Push offcanvas below the WP admin bar when logged in.
|
||||
if ( is_admin_bar_showing() ) {
|
||||
wp_add_inline_style( 'wp-bootstrap-style',
|
||||
'.offcanvas { padding-top: var(--wp-admin--admin-bar--height, 32px); }'
|
||||
'@media (max-width: 991.98px) { .offcanvas { padding-top: var(--wp-admin--admin-bar--height, 32px); } }'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,6 +320,24 @@ add_action( 'save_post_wp_global_styles', function () {
|
||||
delete_transient( 'wp_bootstrap_variation_css_' . md5( get_stylesheet() ) );
|
||||
} );
|
||||
|
||||
/**
|
||||
* Invalidate sidebar transient caches when content changes.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*/
|
||||
add_action( 'save_post', function () {
|
||||
delete_transient( 'wp_bootstrap_sidebar_recent_4' );
|
||||
} );
|
||||
add_action( 'create_post_tag', function () {
|
||||
delete_transient( 'wp_bootstrap_sidebar_tags_15' );
|
||||
} );
|
||||
add_action( 'edit_post_tag', function () {
|
||||
delete_transient( 'wp_bootstrap_sidebar_tags_15' );
|
||||
} );
|
||||
add_action( 'delete_post_tag', function () {
|
||||
delete_transient( 'wp_bootstrap_sidebar_tags_15' );
|
||||
} );
|
||||
|
||||
/**
|
||||
* Build Bootstrap surface CSS variables for a given background/foreground pair.
|
||||
*
|
||||
@@ -378,17 +396,12 @@ endif;
|
||||
* @return string RGB triplet (e.g. "13,110,253") or empty string on failure.
|
||||
*/
|
||||
if ( ! function_exists( 'wp_bootstrap_hex_to_rgb' ) ) :
|
||||
function wp_bootstrap_hex_to_rgb( $hex ) {
|
||||
$hex = ltrim( $hex, '#' );
|
||||
if ( strlen( $hex ) === 3 ) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
if ( strlen( $hex ) !== 6 ) {
|
||||
function wp_bootstrap_hex_to_rgb( string $hex ): string {
|
||||
$rgb = wp_bootstrap_hex_to_rgb_array( $hex );
|
||||
if ( ! $rgb ) {
|
||||
return '';
|
||||
}
|
||||
return hexdec( substr( $hex, 0, 2 ) ) . ','
|
||||
. hexdec( substr( $hex, 2, 2 ) ) . ','
|
||||
. hexdec( substr( $hex, 4, 2 ) );
|
||||
return implode( ',', $rgb );
|
||||
}
|
||||
endif;
|
||||
|
||||
@@ -403,7 +416,7 @@ endif;
|
||||
* @return string Resulting hex color.
|
||||
*/
|
||||
if ( ! function_exists( 'wp_bootstrap_mix_hex' ) ) :
|
||||
function wp_bootstrap_mix_hex( $color1, $color2, $weight ) {
|
||||
function wp_bootstrap_mix_hex( string $color1, string $color2, float $weight ): string {
|
||||
$c1 = wp_bootstrap_hex_to_rgb_array( $color1 );
|
||||
$c2 = wp_bootstrap_hex_to_rgb_array( $color2 );
|
||||
if ( ! $c1 || ! $c2 ) {
|
||||
@@ -425,12 +438,12 @@ endif;
|
||||
* @return array|false Array of [r, g, b] or false on failure.
|
||||
*/
|
||||
if ( ! function_exists( 'wp_bootstrap_hex_to_rgb_array' ) ) :
|
||||
function wp_bootstrap_hex_to_rgb_array( $hex ) {
|
||||
function wp_bootstrap_hex_to_rgb_array( string $hex ): array|false {
|
||||
$hex = ltrim( $hex, '#' );
|
||||
if ( strlen( $hex ) === 3 ) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
if ( strlen( $hex ) !== 6 ) {
|
||||
if ( strlen( $hex ) !== 6 || ! ctype_xdigit( $hex ) ) {
|
||||
return false;
|
||||
}
|
||||
return array(
|
||||
@@ -450,7 +463,7 @@ endif;
|
||||
* @return float Relative luminance.
|
||||
*/
|
||||
if ( ! function_exists( 'wp_bootstrap_relative_luminance' ) ) :
|
||||
function wp_bootstrap_relative_luminance( $hex ) {
|
||||
function wp_bootstrap_relative_luminance( string $hex ): float {
|
||||
$rgb = wp_bootstrap_hex_to_rgb_array( $hex );
|
||||
if ( ! $rgb ) {
|
||||
return 0.0;
|
||||
@@ -757,6 +770,12 @@ if ( ! function_exists( 'wp_bootstrap_block_styles' ) ) :
|
||||
'inline_style' => '
|
||||
.is-style-separator-wide { max-width: none; }',
|
||||
) );
|
||||
|
||||
// core/list - List Group (Bootstrap).
|
||||
register_block_style( 'core/list', array(
|
||||
'name' => 'list-group',
|
||||
'label' => __( 'List Group', 'wp-bootstrap' ),
|
||||
) );
|
||||
}
|
||||
endif;
|
||||
add_action( 'init', 'wp_bootstrap_block_styles' );
|
||||
@@ -790,6 +809,40 @@ if ( ! function_exists( 'wp_bootstrap_init_templates' ) ) :
|
||||
endif;
|
||||
add_action( 'after_setup_theme', 'wp_bootstrap_init_templates' );
|
||||
|
||||
/**
|
||||
* Initialize block renderer for Bootstrap 5 class injection.
|
||||
*
|
||||
* Hooks per-block render_block filters to inject Bootstrap classes
|
||||
* (e.g. .table, .btn, .img-fluid) into core block HTML on the frontend.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
if ( ! function_exists( 'wp_bootstrap_init_block_renderer' ) ) :
|
||||
function wp_bootstrap_init_block_renderer() {
|
||||
if ( class_exists( '\\WPBootstrap\\Block\\BlockRenderer' ) ) {
|
||||
new \WPBootstrap\Block\BlockRenderer();
|
||||
}
|
||||
}
|
||||
endif;
|
||||
add_action( 'after_setup_theme', 'wp_bootstrap_init_block_renderer' );
|
||||
|
||||
/**
|
||||
* Initialize widget renderer for Bootstrap 5 card wrappers.
|
||||
*
|
||||
* Hooks dynamic_sidebar_params to wrap sidebar widgets in Bootstrap
|
||||
* card components with proper heading structure.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
if ( ! function_exists( 'wp_bootstrap_init_widget_renderer' ) ) :
|
||||
function wp_bootstrap_init_widget_renderer() {
|
||||
if ( class_exists( '\\WPBootstrap\\Block\\WidgetRenderer' ) ) {
|
||||
new \WPBootstrap\Block\WidgetRenderer();
|
||||
}
|
||||
}
|
||||
endif;
|
||||
add_action( 'after_setup_theme', 'wp_bootstrap_init_widget_renderer' );
|
||||
|
||||
/**
|
||||
* Customize comment form fields with Bootstrap classes.
|
||||
*
|
||||
|
||||
303
inc/Block/BlockRenderer.php
Normal file
303
inc/Block/BlockRenderer.php
Normal file
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
/**
|
||||
* Block Renderer.
|
||||
*
|
||||
* Injects Bootstrap 5 classes into WordPress core block HTML output
|
||||
* on the frontend. Uses WP_HTML_Tag_Processor for safe HTML manipulation.
|
||||
*
|
||||
* @package WPBootstrap\Block
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
namespace WPBootstrap\Block;
|
||||
|
||||
use WP_HTML_Tag_Processor;
|
||||
use WP_Block;
|
||||
|
||||
class BlockRenderer
|
||||
{
|
||||
/**
|
||||
* Map of WordPress preset color slugs to Bootstrap button variants.
|
||||
*/
|
||||
private const COLOR_VARIANTS = [
|
||||
'primary' => 'primary',
|
||||
'secondary' => 'secondary',
|
||||
'success' => 'success',
|
||||
'danger' => 'danger',
|
||||
'warning' => 'warning',
|
||||
'info' => 'info',
|
||||
'light' => 'light',
|
||||
'dark' => 'dark',
|
||||
];
|
||||
|
||||
/**
|
||||
* Register render_block filters for each supported block type.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
if ( is_admin() || wp_doing_ajax() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$blocks = $this->getBlockHandlers();
|
||||
|
||||
/**
|
||||
* Filters the map of block names to handler method names.
|
||||
*
|
||||
* Child themes can remove blocks or add new ones.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*
|
||||
* @param array $blocks Map of 'core/block-name' => 'methodName'.
|
||||
*/
|
||||
$blocks = apply_filters( 'wp_bootstrap_block_renderer_blocks', $blocks );
|
||||
|
||||
foreach ( $blocks as $blockName => $method ) {
|
||||
if ( method_exists( $this, $method ) ) {
|
||||
add_filter( "render_block_{$blockName}", [ $this, $method ], 10, 3 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default map of block names to handler methods.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getBlockHandlers(): array
|
||||
{
|
||||
return [
|
||||
'core/table' => 'renderTable',
|
||||
'core/button' => 'renderButton',
|
||||
'core/buttons' => 'renderButtons',
|
||||
'core/image' => 'renderImage',
|
||||
'core/search' => 'renderSearch',
|
||||
'core/quote' => 'renderQuote',
|
||||
'core/pullquote' => 'renderPullquote',
|
||||
'core/list' => 'renderList',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap table classes.
|
||||
*
|
||||
* Injects .table on <table>; adds .table-striped when WP stripes style is active.
|
||||
*/
|
||||
public function renderTable( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( 'table' ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'table' );
|
||||
|
||||
$className = $block['attrs']['className'] ?? '';
|
||||
if ( str_contains( $className, 'is-style-stripes' ) ) {
|
||||
$processor->add_class( 'table-striped' );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap button classes.
|
||||
*
|
||||
* Injects .btn + color variant on .wp-block-button__link.
|
||||
* Maps WP preset color slugs to Bootstrap btn-{variant} / btn-outline-{variant}.
|
||||
*/
|
||||
public function renderButton( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( [ 'class_name' => 'wp-block-button__link' ] ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'btn' );
|
||||
|
||||
$attrs = $block['attrs'] ?? [];
|
||||
|
||||
// Gradient buttons: just .btn, inline style handles the color.
|
||||
if ( ! empty( $attrs['gradient'] ) ) {
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
$className = $attrs['className'] ?? '';
|
||||
$isOutline = str_contains( $className, 'is-style-outline' );
|
||||
|
||||
if ( $isOutline ) {
|
||||
$colorSlug = $attrs['textColor'] ?? 'primary';
|
||||
$variant = self::COLOR_VARIANTS[ $colorSlug ] ?? 'primary';
|
||||
$processor->add_class( 'btn-outline-' . $variant );
|
||||
} else {
|
||||
$colorSlug = $attrs['backgroundColor'] ?? 'primary';
|
||||
$variant = self::COLOR_VARIANTS[ $colorSlug ] ?? 'primary';
|
||||
$processor->add_class( 'btn-' . $variant );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap flex utilities to button group wrapper.
|
||||
*/
|
||||
public function renderButtons( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( [ 'class_name' => 'wp-block-buttons' ] ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'd-flex' );
|
||||
$processor->add_class( 'flex-wrap' );
|
||||
$processor->add_class( 'gap-2' );
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add .img-fluid to block images.
|
||||
*/
|
||||
public function renderImage( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( 'img' ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'img-fluid' );
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap form-control and button classes to search block.
|
||||
*/
|
||||
public function renderSearch( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
// Add .input-group to the inner wrapper for seamless input + button.
|
||||
if ( $processor->next_tag( [ 'class_name' => 'wp-block-search__inside-wrapper' ] ) ) {
|
||||
$processor->add_class( 'input-group' );
|
||||
}
|
||||
|
||||
if ( $processor->next_tag( [ 'class_name' => 'wp-block-search__input' ] ) ) {
|
||||
$processor->add_class( 'form-control' );
|
||||
}
|
||||
|
||||
if ( $processor->next_tag( [ 'class_name' => 'wp-block-search__button' ] ) ) {
|
||||
$processor->add_class( 'btn' );
|
||||
$processor->add_class( 'btn-primary' );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap blockquote classes to quote block.
|
||||
*/
|
||||
public function renderQuote( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( 'blockquote' ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'blockquote' );
|
||||
|
||||
if ( $processor->next_tag( 'cite' ) ) {
|
||||
$processor->add_class( 'blockquote-footer' );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap blockquote classes to pullquote block.
|
||||
*/
|
||||
public function renderPullquote( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
if ( ! $processor->next_tag( 'blockquote' ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor->add_class( 'blockquote' );
|
||||
|
||||
if ( $processor->next_tag( 'cite' ) ) {
|
||||
$processor->add_class( 'blockquote-footer' );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Bootstrap list-group classes when list-group style is selected.
|
||||
*
|
||||
* Only modifies lists with the is-style-list-group block style.
|
||||
*/
|
||||
public function renderList( string $content, array $block, ?WP_Block $instance = null ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$className = $block['attrs']['className'] ?? '';
|
||||
|
||||
if ( ! str_contains( $className, 'is-style-list-group' ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$processor = new WP_HTML_Tag_Processor( $content );
|
||||
|
||||
$listTag = ! empty( $block['attrs']['ordered'] ) ? 'ol' : 'ul';
|
||||
if ( $processor->next_tag( $listTag ) ) {
|
||||
$processor->add_class( 'list-group' );
|
||||
}
|
||||
|
||||
while ( $processor->next_tag( 'li' ) ) {
|
||||
$processor->add_class( 'list-group-item' );
|
||||
}
|
||||
|
||||
return $processor->get_updated_html();
|
||||
}
|
||||
}
|
||||
112
inc/Block/WidgetRenderer.php
Normal file
112
inc/Block/WidgetRenderer.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
/**
|
||||
* Widget Renderer.
|
||||
*
|
||||
* Transforms sidebar widget wrappers into Bootstrap 5 card components
|
||||
* and adjusts block widget content (headings, lists) for Bootstrap styling.
|
||||
*
|
||||
* Card structure:
|
||||
* With title: card → card-body → h4.card-title → content
|
||||
* Without title: card → card-body → content
|
||||
*
|
||||
* The title is placed inside card-body as a card-title. This avoids
|
||||
* broken HTML when widgets omit the title (WordPress skips before_title
|
||||
* and after_title entirely when there is no title to output).
|
||||
*
|
||||
* @package WPBootstrap\Block
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
namespace WPBootstrap\Block;
|
||||
|
||||
use WP_HTML_Tag_Processor;
|
||||
|
||||
class WidgetRenderer
|
||||
{
|
||||
/**
|
||||
* Register filters for widget output transformation.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
if ( is_admin() || wp_doing_ajax() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter( 'dynamic_sidebar_params', [ $this, 'wrapWidgetInCard' ] );
|
||||
add_filter( 'widget_block_content', [ $this, 'processBlockWidgetContent' ], 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Restructure widget wrapper as a Bootstrap card.
|
||||
*
|
||||
* Uses card-body for all content with card-title for the heading.
|
||||
* This structure works correctly whether or not the widget outputs a title.
|
||||
*
|
||||
* Note: WordPress runs sprintf on before_widget BEFORE this filter,
|
||||
* so %1$s/%2$s are already replaced. We must use the processed values.
|
||||
*
|
||||
* @param array $params Sidebar parameters.
|
||||
* @return array Modified parameters.
|
||||
*/
|
||||
public function wrapWidgetInCard( array $params ): array
|
||||
{
|
||||
$widgetId = $params[0]['widget_id'] ?? '';
|
||||
$beforeWidget = $params[0]['before_widget'] ?? '';
|
||||
|
||||
// Extract widget-type classes (e.g. widget_block, widget_search)
|
||||
// from the already-processed before_widget, skipping generic
|
||||
// wrapper classes that we're replacing.
|
||||
$widgetClasses = '';
|
||||
if ( preg_match( '/class="([^"]*)"/', $beforeWidget, $matches ) ) {
|
||||
$original = array_filter( explode( ' ', $matches[1] ) );
|
||||
$skip = [ 'widget', 'mb-4' ];
|
||||
$kept = array_diff( $original, $skip );
|
||||
$widgetClasses = implode( ' ', $kept );
|
||||
}
|
||||
|
||||
$params[0]['before_widget'] = sprintf(
|
||||
'<div id="%s" class="card mb-3 widget %s"><div class="card-body">',
|
||||
esc_attr( $widgetId ),
|
||||
esc_attr( $widgetClasses )
|
||||
);
|
||||
$params[0]['after_widget'] = '</div></div>';
|
||||
$params[0]['before_title'] = '<h4 class="card-title h6 text-uppercase fw-semibold">';
|
||||
$params[0]['after_title'] = '</h4>';
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process block widget content to downgrade h2 headings to h4.
|
||||
*
|
||||
* Block widgets render their headings as <h2 class="wp-block-heading">.
|
||||
* Inside a sidebar card, h2 is too large — replace with h4 for proper
|
||||
* visual hierarchy.
|
||||
*
|
||||
* @param string $content Widget block content.
|
||||
* @param array $instance Widget instance data.
|
||||
* @param \WP_Widget $widget Widget object.
|
||||
* @return string Modified content.
|
||||
*/
|
||||
public function processBlockWidgetContent( string $content, array $instance, \WP_Widget $widget ): string
|
||||
{
|
||||
if ( empty( $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// Replace <h2 ... wp-block-heading ...>...</h2> with <h4> pairs.
|
||||
// Single regex ensures only headings with wp-block-heading class are
|
||||
// downgraded, preventing mismatched tags if a widget contains other h2s.
|
||||
$content = preg_replace(
|
||||
'/<h2(\s+class="[^"]*wp-block-heading[^"]*"[^>]*)>(.*?)<\/h2>/s',
|
||||
'<h4$1>$2</h4>',
|
||||
$content
|
||||
);
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,9 @@ class ContextBuilder
|
||||
$context['search_query'] = get_search_query();
|
||||
}
|
||||
|
||||
// Sidebar layout detection.
|
||||
// Sidebar: determine once whether the current page needs sidebar data.
|
||||
$needsSidebar = false;
|
||||
|
||||
if (is_home()) {
|
||||
$pageId = (int) get_option('page_for_posts');
|
||||
if ($pageId) {
|
||||
@@ -67,15 +69,15 @@ class ContextBuilder
|
||||
$context['layout'] = 'sidebar';
|
||||
}
|
||||
}
|
||||
$context['sidebar'] = $this->getSidebarData();
|
||||
$needsSidebar = true;
|
||||
} elseif (is_singular('post')) {
|
||||
$needsSidebar = true;
|
||||
} elseif (is_page() && get_page_template_slug() === 'page-sidebar') {
|
||||
$needsSidebar = true;
|
||||
}
|
||||
|
||||
// Sidebar data for pages/posts using the "Page with Sidebar" template.
|
||||
if (is_page() || is_singular('post')) {
|
||||
$slug = get_page_template_slug();
|
||||
if ($slug === 'page-sidebar') {
|
||||
$context['sidebar'] = $this->getSidebarData();
|
||||
}
|
||||
if ($needsSidebar) {
|
||||
$context['sidebar'] = $this->getSidebarData();
|
||||
}
|
||||
|
||||
return $context;
|
||||
@@ -304,21 +306,26 @@ class ContextBuilder
|
||||
|
||||
/**
|
||||
* Build a nested comment tree from flat comments.
|
||||
*
|
||||
* Uses a parent-indexed lookup map for O(n) performance instead of
|
||||
* scanning all comments at each recursion level (O(n^2)).
|
||||
*/
|
||||
private function buildCommentTree(array $comments, int $parentId = 0): array
|
||||
private function buildCommentTree(array $comments, int $parentId = 0, ?array $index = null): array
|
||||
{
|
||||
if ($index === null) {
|
||||
$index = [];
|
||||
foreach ($comments as $comment) {
|
||||
$parent = (int) $comment->comment_parent;
|
||||
$index[$parent][] = $comment;
|
||||
}
|
||||
}
|
||||
|
||||
$tree = [];
|
||||
|
||||
foreach ($comments as $comment) {
|
||||
if ((int) $comment->comment_parent !== $parentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($index[$parentId] ?? [] as $comment) {
|
||||
$tree[] = [
|
||||
'id' => (int) $comment->comment_ID,
|
||||
// Escape at source — comment_author is user-supplied, store as safe text.
|
||||
'author' => esc_html($comment->comment_author),
|
||||
// esc_url() strips dangerous schemes (javascript:, data:) and encodes for HTML.
|
||||
'author_url' => esc_url($comment->comment_author_url),
|
||||
'avatar_url' => get_avatar_url($comment, ['size' => 40]),
|
||||
'date' => get_comment_date('', $comment),
|
||||
@@ -331,7 +338,7 @@ class ContextBuilder
|
||||
'depth' => 1,
|
||||
'max_depth' => get_option('thread_comments_depth', 5),
|
||||
], $comment),
|
||||
'children' => $this->buildCommentTree($comments, (int) $comment->comment_ID),
|
||||
'children' => $this->buildCommentTree($comments, (int) $comment->comment_ID, $index),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -447,9 +454,17 @@ class ContextBuilder
|
||||
|
||||
/**
|
||||
* Get recent posts for sidebar.
|
||||
*
|
||||
* Cached via transient for 1 hour; invalidated on save_post.
|
||||
*/
|
||||
private function getSidebarRecentPosts(int $count = 4): array
|
||||
{
|
||||
$transient_key = 'wp_bootstrap_sidebar_recent_' . $count;
|
||||
$cached = get_transient($transient_key);
|
||||
if (false !== $cached) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$query = new \WP_Query([
|
||||
'posts_per_page' => $count,
|
||||
'orderby' => 'date',
|
||||
@@ -469,14 +484,23 @@ class ContextBuilder
|
||||
wp_reset_postdata();
|
||||
}
|
||||
|
||||
set_transient($transient_key, $posts, HOUR_IN_SECONDS);
|
||||
return $posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags for sidebar tag cloud.
|
||||
*
|
||||
* Cached via transient for 1 hour; invalidated on tag changes.
|
||||
*/
|
||||
private function getSidebarTags(int $count = 15): array
|
||||
{
|
||||
$transient_key = 'wp_bootstrap_sidebar_tags_' . $count;
|
||||
$cached = get_transient($transient_key);
|
||||
if (false !== $cached) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$tags = get_tags([
|
||||
'number' => $count,
|
||||
'orderby' => 'count',
|
||||
@@ -496,6 +520,7 @@ class ContextBuilder
|
||||
];
|
||||
}
|
||||
|
||||
set_transient($transient_key, $items, HOUR_IN_SECONDS);
|
||||
return $items;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,11 @@ class TemplateController
|
||||
}
|
||||
|
||||
if (is_singular('post')) {
|
||||
return 'pages/single.html.twig';
|
||||
$slug = get_page_template_slug();
|
||||
return match ($slug) {
|
||||
'page-full-width' => 'pages/single.html.twig',
|
||||
default => 'pages/single-sidebar.html.twig',
|
||||
};
|
||||
}
|
||||
|
||||
if (is_page()) {
|
||||
|
||||
@@ -165,5 +165,10 @@ class TwigService
|
||||
{
|
||||
$this->twig->addFilter(new TwigFilter('wpautop', 'wpautop', ['is_safe' => ['html']]));
|
||||
$this->twig->addFilter(new TwigFilter('wp_kses_post', 'wp_kses_post', ['is_safe' => ['html']]));
|
||||
|
||||
// Escaping filters — same functions registered above, but as filters for |esc_html syntax.
|
||||
$this->twig->addFilter(new TwigFilter('esc_html', 'esc_html', ['is_safe' => ['html']]));
|
||||
$this->twig->addFilter(new TwigFilter('esc_attr', 'esc_attr', ['is_safe' => ['html']]));
|
||||
$this->twig->addFilter(new TwigFilter('esc_url', 'esc_url', ['is_safe' => ['html']]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"copy:js": "copyfiles -f node_modules/bootstrap/dist/js/bootstrap.bundle.min.js node_modules/bootstrap/dist/js/bootstrap.bundle.min.js.map assets/js/",
|
||||
"copy:theme-js": "copyfiles -f src/js/dark-mode.js assets/js/",
|
||||
"copy:icons": "copyfiles -f node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff2 assets/fonts/",
|
||||
"test": "composer exec -- phpunit",
|
||||
"prebuild": "npm run test",
|
||||
"build": "npm run copy:js && npm run copy:theme-js && npm run copy:icons && npm run scss && npm run scss:rtl && npm run postcss",
|
||||
"watch": "npm run copy:js && npm run scss:watch",
|
||||
"dev": "npm run watch"
|
||||
|
||||
21
phpunit.xml.dist
Normal file
21
phpunit.xml.dist
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
<include>
|
||||
<directory>inc</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 284 KiB |
145
src/scss/_widgets.scss
Normal file
145
src/scss/_widgets.scss
Normal file
@@ -0,0 +1,145 @@
|
||||
// Widget Bootstrap 5 styling
|
||||
// Targets sidebar widget inner content rendered by WordPress core widgets.
|
||||
// Block widgets nest content inside .wp-block-group wrappers.
|
||||
|
||||
// Widget headings (block widgets use h4.wp-block-heading after WidgetRenderer transform)
|
||||
.widget .card-title,
|
||||
.widget .wp-block-heading {
|
||||
margin-bottom: $spacer * 0.75;
|
||||
}
|
||||
|
||||
// Widget lists (Recent Posts, Archives, Categories, Recent Comments)
|
||||
// Covers both legacy (ul direct child) and block widget (ul inside .wp-block-group)
|
||||
.widget ul,
|
||||
.widget ol {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
li {
|
||||
padding: $list-group-item-padding-y $list-group-item-padding-x;
|
||||
border-bottom: var(--bs-border-width) solid var(--bs-border-color);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush lists to card edges (negate card-body padding)
|
||||
// Handles both direct children and block widget nesting (.wp-block-group > ul)
|
||||
.widget .card-body > ul,
|
||||
.widget .card-body > ol,
|
||||
.widget .card-body > nav > ul,
|
||||
.widget .card-body > .wp-block-group > ul,
|
||||
.widget .card-body > .wp-block-group > ol {
|
||||
margin: 0 calc(-1 * var(--bs-card-spacer-x)) calc(-1 * var(--bs-card-spacer-y));
|
||||
|
||||
li:first-child {
|
||||
border-top: var(--bs-border-width) solid var(--bs-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Widget select dropdowns (Archives dropdown, Categories dropdown)
|
||||
.widget select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
font-size: $input-font-size;
|
||||
line-height: $input-line-height;
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
appearance: auto;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
|
||||
&:focus {
|
||||
border-color: $input-focus-border-color;
|
||||
outline: 0;
|
||||
box-shadow: $input-focus-box-shadow;
|
||||
}
|
||||
}
|
||||
|
||||
// Widget search form (legacy get_search_form() widgets)
|
||||
.widget .search-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
.search-field {
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
font-size: $input-font-size;
|
||||
line-height: $input-line-height;
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-body-bg);
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
|
||||
&:focus {
|
||||
border-color: $input-focus-border-color;
|
||||
outline: 0;
|
||||
box-shadow: $input-focus-box-shadow;
|
||||
}
|
||||
}
|
||||
|
||||
.search-submit {
|
||||
@extend .btn;
|
||||
@extend .btn-primary;
|
||||
}
|
||||
}
|
||||
|
||||
// Block search widget — hide label, make input-group flush
|
||||
.widget .wp-block-search {
|
||||
.wp-block-search__label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Tag cloud
|
||||
.widget .tagcloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: $font-size-sm !important; // Override inline font-size from WP
|
||||
line-height: 1.5;
|
||||
color: var(--bs-body-color);
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius-pill);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post date in Recent Posts widget
|
||||
.widget .post-date {
|
||||
display: block;
|
||||
font-size: $small-font-size;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
// Recent Comments styling
|
||||
.widget .recentcomments {
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
@@ -18,5 +18,8 @@
|
||||
// 5. Bootstrap Icons
|
||||
@import "bootstrap-icons/font/bootstrap-icons";
|
||||
|
||||
// 6. Custom styles
|
||||
// 6. Widget styles
|
||||
@import "widgets";
|
||||
|
||||
// 7. Custom styles
|
||||
@import "custom";
|
||||
|
||||
@@ -7,7 +7,7 @@ Description: A modern WordPress Block Theme built from scratch with Bootstrap 5.
|
||||
Requires at least: 6.7
|
||||
Tested up to: 6.7
|
||||
Requires PHP: 8.3
|
||||
Version: 1.0.11
|
||||
Version: 1.1.3
|
||||
License: GNU General Public License v2 or later
|
||||
License URI: http://www.gnu.org/licenses/gpl-2.0.html
|
||||
Text Domain: wp-bootstrap
|
||||
|
||||
10
tests/Stubs/WpBlock.php
Normal file
10
tests/Stubs/WpBlock.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Stub for WordPress WP_Block class.
|
||||
*
|
||||
* Used as a type hint in BlockRenderer handler methods.
|
||||
* Only needs to exist — no functionality required.
|
||||
*/
|
||||
class WP_Block
|
||||
{
|
||||
}
|
||||
135
tests/Stubs/WpHtmlTagProcessor.php
Normal file
135
tests/Stubs/WpHtmlTagProcessor.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
/**
|
||||
* Functional stub for WordPress WP_HTML_Tag_Processor.
|
||||
*
|
||||
* Uses DOMDocument/DOMXPath to provide the subset of functionality
|
||||
* used by BlockRenderer: next_tag(), add_class(), get_updated_html().
|
||||
*
|
||||
* This is NOT a complete implementation — only the methods and query
|
||||
* modes actually used in the theme are supported.
|
||||
*/
|
||||
class WP_HTML_Tag_Processor
|
||||
{
|
||||
private string $html;
|
||||
private \DOMDocument $doc;
|
||||
private \DOMXPath $xpath;
|
||||
private ?\DOMElement $current = null;
|
||||
|
||||
/** @var int[] Object IDs of already-visited elements. */
|
||||
private array $visited = [];
|
||||
|
||||
public function __construct(string $html)
|
||||
{
|
||||
$this->html = $html;
|
||||
$this->doc = new \DOMDocument();
|
||||
$this->doc->encoding = 'UTF-8';
|
||||
|
||||
// Wrap in a full HTML document so <body> is always present.
|
||||
// This ensures get_updated_html() can reliably extract content.
|
||||
@$this->doc->loadHTML(
|
||||
'<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>'
|
||||
. $html
|
||||
. '</body></html>',
|
||||
LIBXML_HTML_NODEFDTD
|
||||
);
|
||||
|
||||
$this->xpath = new \DOMXPath($this->doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to the next matching tag.
|
||||
*
|
||||
* @param string|array|null $query Tag name (string) or ['class_name' => '...'] (array).
|
||||
*/
|
||||
public function next_tag($query = null): bool
|
||||
{
|
||||
$tagName = null;
|
||||
$className = null;
|
||||
|
||||
if (is_string($query)) {
|
||||
$tagName = strtolower($query);
|
||||
} elseif (is_array($query)) {
|
||||
$tagName = isset($query['tag_name']) ? strtolower($query['tag_name']) : null;
|
||||
$className = $query['class_name'] ?? null;
|
||||
}
|
||||
|
||||
// Build XPath — search within <body> only.
|
||||
$body = $this->doc->getElementsByTagName('body')->item(0);
|
||||
if (!$body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$xpathExpr = './/*';
|
||||
$conditions = [];
|
||||
|
||||
if ($tagName !== null) {
|
||||
$conditions[] = sprintf(
|
||||
"translate(local-name(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') = '%s'",
|
||||
$tagName
|
||||
);
|
||||
}
|
||||
|
||||
if ($className !== null) {
|
||||
$conditions[] = sprintf(
|
||||
"contains(concat(' ', normalize-space(@class), ' '), ' %s ')",
|
||||
$className
|
||||
);
|
||||
}
|
||||
|
||||
if ($conditions) {
|
||||
$xpathExpr .= '[' . implode(' and ', $conditions) . ']';
|
||||
}
|
||||
|
||||
$nodes = $this->xpath->query($xpathExpr, $body);
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$oid = spl_object_id($node);
|
||||
if (!in_array($oid, $this->visited, true)) {
|
||||
$this->current = $node;
|
||||
$this->visited[] = $oid;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a CSS class to the current tag (idempotent).
|
||||
*/
|
||||
public function add_class(string $className): bool
|
||||
{
|
||||
if ($this->current === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existing = $this->current->getAttribute('class');
|
||||
$classes = $existing ? array_filter(explode(' ', $existing)) : [];
|
||||
|
||||
if (!in_array($className, $classes, true)) {
|
||||
$classes[] = $className;
|
||||
}
|
||||
|
||||
$this->current->setAttribute('class', implode(' ', $classes));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the modified HTML.
|
||||
*/
|
||||
public function get_updated_html(): string
|
||||
{
|
||||
$body = $this->doc->getElementsByTagName('body')->item(0);
|
||||
if (!$body) {
|
||||
return $this->html;
|
||||
}
|
||||
|
||||
$html = '';
|
||||
foreach ($body->childNodes as $child) {
|
||||
$html .= $this->doc->saveHTML($child);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
10
tests/Stubs/WpWidget.php
Normal file
10
tests/Stubs/WpWidget.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
/**
|
||||
* Stub for WordPress WP_Widget class.
|
||||
*
|
||||
* Used as a type hint in WidgetRenderer::processBlockWidgetContent().
|
||||
* Only needs to exist — no functionality required.
|
||||
*/
|
||||
class WP_Widget
|
||||
{
|
||||
}
|
||||
323
tests/Unit/Block/BlockRendererTest.php
Normal file
323
tests/Unit/Block/BlockRendererTest.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPBootstrap\Tests\Unit\Block;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use WPBootstrap\Block\BlockRenderer;
|
||||
|
||||
class BlockRendererTest extends TestCase
|
||||
{
|
||||
private BlockRenderer $renderer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// Return true so the constructor skips add_filter() registration.
|
||||
Functions\when('is_admin')->justReturn(true);
|
||||
Functions\when('wp_doing_ajax')->justReturn(false);
|
||||
|
||||
$this->renderer = new BlockRenderer();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── core/table ──────────────────────────────────────────────
|
||||
|
||||
public function testRenderTableAddsTableClass(): void
|
||||
{
|
||||
$html = '<figure class="wp-block-table"><table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table></figure>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderTable($html, $block);
|
||||
|
||||
$this->assertStringContainsString('table', $this->classesOf('table', $result));
|
||||
}
|
||||
|
||||
public function testRenderTableWithStripesAddsStripedClass(): void
|
||||
{
|
||||
$html = '<figure class="wp-block-table is-style-stripes"><table><tr><td>1</td></tr></table></figure>';
|
||||
$block = ['attrs' => ['className' => 'is-style-stripes']];
|
||||
$result = $this->renderer->renderTable($html, $block);
|
||||
|
||||
$classes = $this->classesOf('table', $result);
|
||||
$this->assertStringContainsString('table', $classes);
|
||||
$this->assertStringContainsString('table-striped', $classes);
|
||||
}
|
||||
|
||||
public function testRenderTableEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderTable('', []));
|
||||
}
|
||||
|
||||
public function testRenderTableWithoutTableTagReturnsOriginal(): void
|
||||
{
|
||||
$html = '<p>No table here</p>';
|
||||
$this->assertSame($html, $this->renderer->renderTable($html, []));
|
||||
}
|
||||
|
||||
// ── core/button ─────────────────────────────────────────────
|
||||
|
||||
public function testRenderButtonAddsBtnPrimaryByDefault(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$classes = $this->classesOf('a', $result);
|
||||
$this->assertStringContainsString('btn', $classes);
|
||||
$this->assertStringContainsString('btn-primary', $classes);
|
||||
}
|
||||
|
||||
public function testRenderButtonWithBackgroundColor(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => ['backgroundColor' => 'danger']];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$classes = $this->classesOf('a', $result);
|
||||
$this->assertStringContainsString('btn', $classes);
|
||||
$this->assertStringContainsString('btn-danger', $classes);
|
||||
}
|
||||
|
||||
public function testRenderButtonOutlineStyle(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button is-style-outline"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => ['className' => 'is-style-outline', 'textColor' => 'success']];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$classes = $this->classesOf('a', $result);
|
||||
$this->assertStringContainsString('btn', $classes);
|
||||
$this->assertStringContainsString('btn-outline-success', $classes);
|
||||
}
|
||||
|
||||
public function testRenderButtonOutlineDefaultsToPrimary(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button is-style-outline"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => ['className' => 'is-style-outline']];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$this->assertStringContainsString('btn-outline-primary', $this->classesOf('a', $result));
|
||||
}
|
||||
|
||||
public function testRenderButtonWithGradientOnlyAddsBtnBase(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => ['gradient' => 'vivid-cyan-blue']];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$classes = $this->classesOf('a', $result);
|
||||
$this->assertStringContainsString('btn', $classes);
|
||||
$this->assertStringNotContainsString('btn-primary', $classes);
|
||||
}
|
||||
|
||||
public function testRenderButtonWithUnknownColorDefaultsToPrimary(): void
|
||||
{
|
||||
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
|
||||
$block = ['attrs' => ['backgroundColor' => 'custom-teal']];
|
||||
$result = $this->renderer->renderButton($html, $block);
|
||||
|
||||
$this->assertStringContainsString('btn-primary', $this->classesOf('a', $result));
|
||||
}
|
||||
|
||||
public function testRenderButtonEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderButton('', []));
|
||||
}
|
||||
|
||||
// ── core/buttons ────────────────────────────────────────────
|
||||
|
||||
public function testRenderButtonsAddsFlexClasses(): void
|
||||
{
|
||||
$html = '<div class="wp-block-buttons"><div class="wp-block-button"><a class="wp-block-button__link" href="#">A</a></div></div>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderButtons($html, $block);
|
||||
|
||||
$classes = $this->classesOfFirst('.wp-block-buttons', $result);
|
||||
$this->assertStringContainsString('d-flex', $classes);
|
||||
$this->assertStringContainsString('flex-wrap', $classes);
|
||||
$this->assertStringContainsString('gap-2', $classes);
|
||||
}
|
||||
|
||||
public function testRenderButtonsEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderButtons('', []));
|
||||
}
|
||||
|
||||
// ── core/image ──────────────────────────────────────────────
|
||||
|
||||
public function testRenderImageAddsImgFluid(): void
|
||||
{
|
||||
$html = '<figure class="wp-block-image"><img src="photo.jpg" alt="Photo"></figure>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderImage($html, $block);
|
||||
|
||||
$this->assertStringContainsString('img-fluid', $this->classesOf('img', $result));
|
||||
}
|
||||
|
||||
public function testRenderImageEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderImage('', []));
|
||||
}
|
||||
|
||||
// ── core/search ─────────────────────────────────────────────
|
||||
|
||||
public function testRenderSearchAddsBootstrapClasses(): void
|
||||
{
|
||||
$html = '<form class="wp-block-search" role="search">'
|
||||
. '<div class="wp-block-search__inside-wrapper">'
|
||||
. '<input class="wp-block-search__input" type="search" placeholder="Search">'
|
||||
. '<button class="wp-block-search__button" type="submit">Search</button>'
|
||||
. '</div></form>';
|
||||
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderSearch($html, $block);
|
||||
|
||||
$this->assertStringContainsString('input-group', $result);
|
||||
$this->assertStringContainsString('form-control', $result);
|
||||
$this->assertStringContainsString('btn-primary', $result);
|
||||
}
|
||||
|
||||
public function testRenderSearchEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderSearch('', []));
|
||||
}
|
||||
|
||||
// ── core/quote ──────────────────────────────────────────────
|
||||
|
||||
public function testRenderQuoteAddsBlockquoteClass(): void
|
||||
{
|
||||
$html = '<blockquote class="wp-block-quote"><p>Quote text</p></blockquote>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderQuote($html, $block);
|
||||
|
||||
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
|
||||
}
|
||||
|
||||
public function testRenderQuoteWithCiteAddsFooterClass(): void
|
||||
{
|
||||
$html = '<blockquote class="wp-block-quote"><p>Quote</p><cite>Author</cite></blockquote>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderQuote($html, $block);
|
||||
|
||||
$this->assertStringContainsString('blockquote-footer', $result);
|
||||
}
|
||||
|
||||
public function testRenderQuoteWithoutCite(): void
|
||||
{
|
||||
$html = '<blockquote class="wp-block-quote"><p>Quote only</p></blockquote>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderQuote($html, $block);
|
||||
|
||||
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
|
||||
$this->assertStringNotContainsString('blockquote-footer', $result);
|
||||
}
|
||||
|
||||
public function testRenderQuoteEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderQuote('', []));
|
||||
}
|
||||
|
||||
// ── core/pullquote ──────────────────────────────────────────
|
||||
|
||||
public function testRenderPullquoteAddsBlockquoteClass(): void
|
||||
{
|
||||
$html = '<figure class="wp-block-pullquote"><blockquote><p>Pull</p></blockquote></figure>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderPullquote($html, $block);
|
||||
|
||||
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
|
||||
}
|
||||
|
||||
public function testRenderPullquoteWithCiteAddsFooterClass(): void
|
||||
{
|
||||
$html = '<figure class="wp-block-pullquote"><blockquote><p>Pull</p><cite>Source</cite></blockquote></figure>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderPullquote($html, $block);
|
||||
|
||||
$this->assertStringContainsString('blockquote-footer', $result);
|
||||
}
|
||||
|
||||
public function testRenderPullquoteEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderPullquote('', []));
|
||||
}
|
||||
|
||||
// ── core/list ───────────────────────────────────────────────
|
||||
|
||||
public function testRenderListGroupAddsClasses(): void
|
||||
{
|
||||
$html = '<ul class="is-style-list-group"><li>A</li><li>B</li><li>C</li></ul>';
|
||||
$block = ['attrs' => ['className' => 'is-style-list-group']];
|
||||
$result = $this->renderer->renderList($html, $block);
|
||||
|
||||
$this->assertStringContainsString('list-group', $this->classesOf('ul', $result));
|
||||
$this->assertStringContainsString('list-group-item', $result);
|
||||
}
|
||||
|
||||
public function testRenderListGroupOrderedList(): void
|
||||
{
|
||||
$html = '<ol class="is-style-list-group"><li>1</li><li>2</li></ol>';
|
||||
$block = ['attrs' => ['className' => 'is-style-list-group', 'ordered' => true]];
|
||||
$result = $this->renderer->renderList($html, $block);
|
||||
|
||||
$this->assertStringContainsString('list-group', $this->classesOf('ol', $result));
|
||||
}
|
||||
|
||||
public function testRenderListWithoutGroupStyleReturnsUnchanged(): void
|
||||
{
|
||||
$html = '<ul><li>A</li></ul>';
|
||||
$block = ['attrs' => []];
|
||||
$result = $this->renderer->renderList($html, $block);
|
||||
|
||||
$this->assertStringNotContainsString('list-group', $result);
|
||||
}
|
||||
|
||||
public function testRenderListEmptyContentReturnsEmpty(): void
|
||||
{
|
||||
$this->assertSame('', $this->renderer->renderList('', []));
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the class attribute value of the first matching tag.
|
||||
*/
|
||||
private function classesOf(string $tagName, string $html): string
|
||||
{
|
||||
$doc = new \DOMDocument();
|
||||
@$doc->loadHTML('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>', LIBXML_HTML_NODEFDTD);
|
||||
$elements = $doc->getElementsByTagName($tagName);
|
||||
|
||||
return $elements->length > 0 ? ($elements->item(0)->getAttribute('class') ?? '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract classes from the first element matching a CSS selector-style class.
|
||||
*/
|
||||
private function classesOfFirst(string $selector, string $html): string
|
||||
{
|
||||
$className = ltrim($selector, '.');
|
||||
$doc = new \DOMDocument();
|
||||
@$doc->loadHTML('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>', LIBXML_HTML_NODEFDTD);
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$body = $doc->getElementsByTagName('body')->item(0);
|
||||
if (!$body) {
|
||||
return '';
|
||||
}
|
||||
$nodes = $xpath->query(sprintf(
|
||||
".//*[contains(concat(' ', normalize-space(@class), ' '), ' %s ')]",
|
||||
$className
|
||||
), $body);
|
||||
|
||||
return $nodes->length > 0 ? ($nodes->item(0)->getAttribute('class') ?? '') : '';
|
||||
}
|
||||
}
|
||||
173
tests/Unit/Block/WidgetRendererTest.php
Normal file
173
tests/Unit/Block/WidgetRendererTest.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPBootstrap\Tests\Unit\Block;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use WPBootstrap\Block\WidgetRenderer;
|
||||
|
||||
class WidgetRendererTest extends TestCase
|
||||
{
|
||||
private WidgetRenderer $renderer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// Skip constructor filter registration.
|
||||
Functions\when('is_admin')->justReturn(true);
|
||||
Functions\when('wp_doing_ajax')->justReturn(false);
|
||||
|
||||
$this->renderer = new WidgetRenderer();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── wrapWidgetInCard ────────────────────────────────────────
|
||||
|
||||
public function testWrapWidgetInCardSetsCardStructure(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = $this->makeParams('block-2', 'widget mb-4 widget_block');
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
$this->assertStringContainsString('card', $result[0]['before_widget']);
|
||||
$this->assertStringContainsString('card-body', $result[0]['before_widget']);
|
||||
$this->assertSame('</div></div>', $result[0]['after_widget']);
|
||||
}
|
||||
|
||||
public function testWrapWidgetInCardSetsCardTitle(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = $this->makeParams('block-3', 'widget mb-4 widget_block');
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
$this->assertStringContainsString('<h4', $result[0]['before_title']);
|
||||
$this->assertStringContainsString('card-title', $result[0]['before_title']);
|
||||
$this->assertSame('</h4>', $result[0]['after_title']);
|
||||
}
|
||||
|
||||
public function testWrapWidgetInCardPreservesWidgetId(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = $this->makeParams('search-1', 'widget mb-4 widget_search');
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
$this->assertStringContainsString('id="search-1"', $result[0]['before_widget']);
|
||||
}
|
||||
|
||||
public function testWrapWidgetInCardPreservesWidgetTypeClasses(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = $this->makeParams('block-5', 'widget mb-4 widget_block wp-block-heading');
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
// widget_block and wp-block-heading are kept; widget and mb-4 are removed.
|
||||
$this->assertStringContainsString('widget_block', $result[0]['before_widget']);
|
||||
$this->assertStringContainsString('wp-block-heading', $result[0]['before_widget']);
|
||||
}
|
||||
|
||||
public function testWrapWidgetInCardFiltersGenericClasses(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = $this->makeParams('block-2', 'widget mb-4 widget_block');
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
// The card wrapper adds its own "widget" class, but the extracted
|
||||
// classes should not include the generic "widget" or "mb-4".
|
||||
// Count occurrences: "card mb-3 widget " + "widget_block" should have exactly one "widget".
|
||||
preg_match_all('/\bwidget\b/', $result[0]['before_widget'], $matches);
|
||||
$this->assertCount(1, $matches[0], 'Generic "widget" class should appear once (from card wrapper), not duplicated');
|
||||
}
|
||||
|
||||
public function testWrapWidgetInCardWithNoExistingClasses(): void
|
||||
{
|
||||
Functions\when('esc_attr')->returnArg();
|
||||
|
||||
$params = [
|
||||
[
|
||||
'widget_id' => 'text-1',
|
||||
'before_widget' => '<div id="text-1">',
|
||||
'after_widget' => '</div>',
|
||||
'before_title' => '<h3>',
|
||||
'after_title' => '</h3>',
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->renderer->wrapWidgetInCard($params);
|
||||
|
||||
$this->assertStringContainsString('card', $result[0]['before_widget']);
|
||||
$this->assertStringContainsString('id="text-1"', $result[0]['before_widget']);
|
||||
}
|
||||
|
||||
// ── processBlockWidgetContent ───────────────────────────────
|
||||
|
||||
public function testProcessBlockWidgetContentReplacesH2WithH4(): void
|
||||
{
|
||||
$content = '<h2 class="wp-block-heading">Categories</h2><ul><li>Cat A</li></ul>';
|
||||
$widget = new \WP_Widget();
|
||||
|
||||
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
|
||||
|
||||
$this->assertStringContainsString('<h4 class="wp-block-heading">', $result);
|
||||
$this->assertStringContainsString('</h4>', $result);
|
||||
$this->assertStringNotContainsString('<h2', $result);
|
||||
$this->assertStringNotContainsString('</h2>', $result);
|
||||
}
|
||||
|
||||
public function testProcessBlockWidgetContentPreservesOtherH2(): void
|
||||
{
|
||||
$content = '<h2 class="custom-heading">Keep me</h2>';
|
||||
$widget = new \WP_Widget();
|
||||
|
||||
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
|
||||
|
||||
// h2 without wp-block-heading class should remain.
|
||||
$this->assertStringContainsString('<h2 class="custom-heading">', $result);
|
||||
}
|
||||
|
||||
public function testProcessBlockWidgetContentEmptyReturnsEmpty(): void
|
||||
{
|
||||
$widget = new \WP_Widget();
|
||||
$this->assertSame('', $this->renderer->processBlockWidgetContent('', [], $widget));
|
||||
}
|
||||
|
||||
public function testProcessBlockWidgetContentMultipleH2(): void
|
||||
{
|
||||
$content = '<h2 class="wp-block-heading">First</h2><p>Text</p><h2 class="wp-block-heading">Second</h2>';
|
||||
$widget = new \WP_Widget();
|
||||
|
||||
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
|
||||
|
||||
$this->assertSame(0, substr_count($result, '<h2'));
|
||||
$this->assertSame(2, substr_count($result, '<h4'));
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────
|
||||
|
||||
private function makeParams(string $widgetId, string $classes): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'widget_id' => $widgetId,
|
||||
'before_widget' => sprintf('<div id="%s" class="%s">', $widgetId, $classes),
|
||||
'after_widget' => '</div>',
|
||||
'before_title' => '<h3 class="sidebar-heading">',
|
||||
'after_title' => '</h3>',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
224
tests/Unit/Template/NavWalkerTest.php
Normal file
224
tests/Unit/Template/NavWalkerTest.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPBootstrap\Tests\Unit\Template;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use WPBootstrap\Template\NavWalker;
|
||||
|
||||
class NavWalkerTest extends TestCase
|
||||
{
|
||||
private NavWalker $walker;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// Default: no page or category is active.
|
||||
Functions\when('is_page')->justReturn(false);
|
||||
Functions\when('is_category')->justReturn(false);
|
||||
|
||||
$this->walker = new NavWalker();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Tree structure ──────────────────────────────────────────
|
||||
|
||||
public function testBuildTreeWithEmptyArray(): void
|
||||
{
|
||||
$this->assertSame([], $this->walker->buildTree([]));
|
||||
}
|
||||
|
||||
public function testBuildTreeWithSingleItem(): void
|
||||
{
|
||||
$items = [$this->makeItem(10, 'Home', '/')];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertCount(1, $tree);
|
||||
$this->assertSame('Home', $tree[0]['title']);
|
||||
$this->assertSame('/', $tree[0]['url']);
|
||||
}
|
||||
|
||||
public function testBuildTreeFlatItemsAllTopLevel(): void
|
||||
{
|
||||
$items = [
|
||||
$this->makeItem(1, 'A', '/a'),
|
||||
$this->makeItem(2, 'B', '/b'),
|
||||
$this->makeItem(3, 'C', '/c'),
|
||||
];
|
||||
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertCount(3, $tree);
|
||||
foreach ($tree as $node) {
|
||||
$this->assertEmpty($node['children']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testBuildTreeWithChildren(): void
|
||||
{
|
||||
$items = [
|
||||
$this->makeItem(10, 'Home', '/'),
|
||||
$this->makeItem(20, 'About', '/about'),
|
||||
$this->makeItem(30, 'Team', '/about/team', parent: 20),
|
||||
$this->makeItem(40, 'Jobs', '/about/jobs', parent: 20),
|
||||
];
|
||||
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertCount(2, $tree);
|
||||
$this->assertSame('About', $tree[1]['title']);
|
||||
$this->assertCount(2, $tree[1]['children']);
|
||||
$this->assertSame('Team', $tree[1]['children'][0]['title']);
|
||||
$this->assertSame('Jobs', $tree[1]['children'][1]['title']);
|
||||
}
|
||||
|
||||
public function testBuildTreeWithMultipleParents(): void
|
||||
{
|
||||
$items = [
|
||||
$this->makeItem(1, 'A', '/a'),
|
||||
$this->makeItem(2, 'B', '/b'),
|
||||
$this->makeItem(3, 'A1', '/a/1', parent: 1),
|
||||
$this->makeItem(4, 'B1', '/b/1', parent: 2),
|
||||
];
|
||||
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertCount(2, $tree);
|
||||
$this->assertCount(1, $tree[0]['children']);
|
||||
$this->assertCount(1, $tree[1]['children']);
|
||||
$this->assertSame('A1', $tree[0]['children'][0]['title']);
|
||||
$this->assertSame('B1', $tree[1]['children'][0]['title']);
|
||||
}
|
||||
|
||||
public function testBuildTreeOrphansAreDropped(): void
|
||||
{
|
||||
$items = [
|
||||
$this->makeItem(1, 'Root', '/'),
|
||||
$this->makeItem(2, 'Orphan', '/orphan', parent: 999),
|
||||
];
|
||||
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertCount(1, $tree);
|
||||
$this->assertSame('Root', $tree[0]['title']);
|
||||
}
|
||||
|
||||
public function testBuildTreeNodeStructure(): void
|
||||
{
|
||||
$items = [$this->makeItem(42, 'Page', '/page', target: '_blank', classes: ['menu-item', 'custom'])];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$node = $tree[0];
|
||||
$this->assertSame(42, $node['id']);
|
||||
$this->assertSame('Page', $node['title']);
|
||||
$this->assertSame('/page', $node['url']);
|
||||
$this->assertSame('_blank', $node['target']);
|
||||
$this->assertSame('menu-item custom', $node['classes']);
|
||||
$this->assertIsBool($node['active']);
|
||||
$this->assertIsArray($node['children']);
|
||||
}
|
||||
|
||||
public function testBuildTreeClassesJoined(): void
|
||||
{
|
||||
$items = [$this->makeItem(1, 'X', '/x', classes: ['nav-item', '', 'menu-item'])];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
// Empty strings are filtered out by array_filter.
|
||||
$this->assertSame('nav-item menu-item', $tree[0]['classes']);
|
||||
}
|
||||
|
||||
public function testBuildTreeIndexIsReset(): void
|
||||
{
|
||||
$items = [
|
||||
$this->makeItem(50, 'A', '/a'),
|
||||
$this->makeItem(100, 'B', '/b'),
|
||||
];
|
||||
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
// array_values resets keys to 0-indexed.
|
||||
$this->assertArrayHasKey(0, $tree);
|
||||
$this->assertArrayHasKey(1, $tree);
|
||||
$this->assertArrayNotHasKey(50, $tree);
|
||||
}
|
||||
|
||||
// ── Active detection ────────────────────────────────────────
|
||||
|
||||
public function testBuildTreeSetsActiveViaCurrentMenuItem(): void
|
||||
{
|
||||
$items = [$this->makeItem(1, 'Active', '/active', classes: ['current-menu-item'])];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertTrue($tree[0]['active']);
|
||||
}
|
||||
|
||||
public function testBuildTreeSetsActiveViaAncestor(): void
|
||||
{
|
||||
$items = [$this->makeItem(1, 'Parent', '/parent', classes: ['current-menu-ancestor'])];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertTrue($tree[0]['active']);
|
||||
}
|
||||
|
||||
public function testBuildTreeSetsActiveViaIsPage(): void
|
||||
{
|
||||
Functions\when('is_page')->alias(fn(int $id): bool => $id === 42);
|
||||
|
||||
$items = [$this->makeItem(1, 'Contact', '/contact', object: 'page', objectId: 42)];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertTrue($tree[0]['active']);
|
||||
}
|
||||
|
||||
public function testBuildTreeSetsActiveViaIsCategory(): void
|
||||
{
|
||||
Functions\when('is_category')->alias(fn(int $id): bool => $id === 7);
|
||||
|
||||
$items = [$this->makeItem(1, 'News', '/news', object: 'category', objectId: 7)];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertTrue($tree[0]['active']);
|
||||
}
|
||||
|
||||
public function testBuildTreeNotActive(): void
|
||||
{
|
||||
$items = [$this->makeItem(1, 'Inactive', '/inactive')];
|
||||
$tree = $this->walker->buildTree($items);
|
||||
|
||||
$this->assertFalse($tree[0]['active']);
|
||||
}
|
||||
|
||||
// ── Helper ──────────────────────────────────────────────────
|
||||
|
||||
private function makeItem(
|
||||
int $id,
|
||||
string $title,
|
||||
string $url,
|
||||
int $parent = 0,
|
||||
string $target = '',
|
||||
array $classes = [],
|
||||
string $object = 'page',
|
||||
int $objectId = 0,
|
||||
): object {
|
||||
return (object) [
|
||||
'ID' => $id,
|
||||
'title' => $title,
|
||||
'url' => $url,
|
||||
'target' => $target,
|
||||
'classes' => $classes,
|
||||
'menu_item_parent' => $parent,
|
||||
'object' => $object,
|
||||
'object_id' => $objectId ?: $id,
|
||||
];
|
||||
}
|
||||
}
|
||||
145
tests/Unit/Template/TemplateControllerTest.php
Normal file
145
tests/Unit/Template/TemplateControllerTest.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WPBootstrap\Tests\Unit\Template;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use WPBootstrap\Template\TemplateController;
|
||||
use ReflectionMethod;
|
||||
|
||||
class TemplateControllerTest extends TestCase
|
||||
{
|
||||
private TemplateController $controller;
|
||||
private ReflectionMethod $resolveTemplate;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
// TemplateController constructor: new ContextBuilder() + add_action().
|
||||
// ContextBuilder constructor: new NavWalker() (no WP calls).
|
||||
Functions\when('is_admin')->justReturn(true);
|
||||
Functions\when('wp_doing_ajax')->justReturn(false);
|
||||
Functions\when('add_action')->justReturn(true);
|
||||
|
||||
// Default: all WP conditionals return false.
|
||||
Functions\when('is_404')->justReturn(false);
|
||||
Functions\when('is_search')->justReturn(false);
|
||||
Functions\when('is_singular')->justReturn(false);
|
||||
Functions\when('is_page')->justReturn(false);
|
||||
Functions\when('is_archive')->justReturn(false);
|
||||
Functions\when('is_home')->justReturn(false);
|
||||
Functions\when('is_category')->justReturn(false);
|
||||
Functions\when('get_page_template_slug')->justReturn('');
|
||||
|
||||
$this->controller = new TemplateController();
|
||||
|
||||
$this->resolveTemplate = new ReflectionMethod($this->controller, 'resolveTemplate');
|
||||
$this->resolveTemplate->setAccessible(true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function resolve(): ?string
|
||||
{
|
||||
return $this->resolveTemplate->invoke($this->controller);
|
||||
}
|
||||
|
||||
// ── Template resolution ─────────────────────────────────────
|
||||
|
||||
public function testResolveTemplate404(): void
|
||||
{
|
||||
Functions\when('is_404')->justReturn(true);
|
||||
|
||||
$this->assertSame('pages/404.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateSearch(): void
|
||||
{
|
||||
Functions\when('is_search')->justReturn(true);
|
||||
|
||||
$this->assertSame('pages/search.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateSinglePostDefault(): void
|
||||
{
|
||||
Functions\when('is_singular')->alias(fn($type = '') => $type === 'post');
|
||||
|
||||
$this->assertSame('pages/single-sidebar.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateSinglePostFullWidth(): void
|
||||
{
|
||||
Functions\when('is_singular')->alias(fn($type = '') => $type === 'post');
|
||||
Functions\when('get_page_template_slug')->justReturn('page-full-width');
|
||||
|
||||
$this->assertSame('pages/single.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplatePageDefault(): void
|
||||
{
|
||||
Functions\when('is_page')->justReturn(true);
|
||||
|
||||
$this->assertSame('pages/page.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplatePageLanding(): void
|
||||
{
|
||||
Functions\when('is_page')->justReturn(true);
|
||||
Functions\when('get_page_template_slug')->justReturn('page-landing');
|
||||
|
||||
$this->assertSame('pages/landing.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplatePageFullWidth(): void
|
||||
{
|
||||
Functions\when('is_page')->justReturn(true);
|
||||
Functions\when('get_page_template_slug')->justReturn('page-full-width');
|
||||
|
||||
$this->assertSame('pages/full-width.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplatePageHero(): void
|
||||
{
|
||||
Functions\when('is_page')->justReturn(true);
|
||||
Functions\when('get_page_template_slug')->justReturn('page-hero');
|
||||
|
||||
$this->assertSame('pages/hero.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplatePageSidebar(): void
|
||||
{
|
||||
Functions\when('is_page')->justReturn(true);
|
||||
Functions\when('get_page_template_slug')->justReturn('page-sidebar');
|
||||
|
||||
$this->assertSame('pages/page-sidebar.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateArchive(): void
|
||||
{
|
||||
Functions\when('is_archive')->justReturn(true);
|
||||
|
||||
$this->assertSame('pages/archive.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateHome(): void
|
||||
{
|
||||
Functions\when('is_home')->justReturn(true);
|
||||
|
||||
$this->assertSame('pages/index.html.twig', $this->resolve());
|
||||
}
|
||||
|
||||
public function testResolveTemplateFallback(): void
|
||||
{
|
||||
// All conditionals already return false in setUp.
|
||||
$this->assertSame('pages/index.html.twig', $this->resolve());
|
||||
}
|
||||
}
|
||||
15
tests/bootstrap.php
Normal file
15
tests/bootstrap.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
/**
|
||||
* PHPUnit bootstrap file.
|
||||
*
|
||||
* Loads Composer autoloader and WordPress class stubs
|
||||
* required for unit testing outside of WordPress.
|
||||
*/
|
||||
|
||||
// Composer autoloader (loads WPBootstrap\* classes + Brain\Monkey).
|
||||
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
// WordPress class stubs (global namespace).
|
||||
require_once __DIR__ . '/Stubs/WpHtmlTagProcessor.php';
|
||||
require_once __DIR__ . '/Stubs/WpBlock.php';
|
||||
require_once __DIR__ . '/Stubs/WpWidget.php';
|
||||
57
views/pages/single-sidebar.html.twig
Normal file
57
views/pages/single-sidebar.html.twig
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<article class="py-4">
|
||||
<header class="mb-4">
|
||||
<h1>{{ post.title }}</h1>
|
||||
{% include 'partials/meta.html.twig' %}
|
||||
</header>
|
||||
|
||||
{% if post.thumbnail %}
|
||||
<figure class="mb-4">
|
||||
<img src="{{ post.thumbnail }}" class="img-fluid rounded post-thumbnail"
|
||||
alt="{{ post.title|e('html_attr') }}"
|
||||
loading="lazy">
|
||||
</figure>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-content">
|
||||
{{ post.content|raw }}
|
||||
</div>
|
||||
|
||||
{% if post.tags|length > 0 %}
|
||||
<div class="mt-4 mb-4">
|
||||
{% for tag in post.tags %}
|
||||
<a href="{{ tag.url }}" class="badge bg-secondary text-decoration-none me-1">
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'partials/post-navigation.html.twig' %}
|
||||
{% include 'partials/comments.html.twig' %}
|
||||
</article>
|
||||
|
||||
{% if more_posts is defined and more_posts|length > 0 %}
|
||||
<section class="py-5 border-top">
|
||||
<h2 class="h4 mb-4">{{ __('More posts') }}</h2>
|
||||
<div class="row row-cols-1 row-cols-md-2 g-4">
|
||||
{% for post in more_posts %}
|
||||
<div class="col">
|
||||
{% include 'components/card-post-grid.html.twig' with {'post': post} only %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
{% include 'partials/sidebar.html.twig' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -7,8 +7,7 @@
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<strong class="small">
|
||||
{% if comment.author_url %}
|
||||
{# author_url is pre-escaped with esc_url() in ContextBuilder #}
|
||||
<a href="{{ comment.author_url|raw }}" class="text-decoration-none text-body" rel="nofollow">
|
||||
<a href="{{ comment.author_url|esc_url }}" class="text-decoration-none text-body" rel="nofollow">
|
||||
{{ comment.author }}
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -19,7 +18,7 @@
|
||||
{{ comment.date }}
|
||||
</time>
|
||||
{% if comment.edit_url %}
|
||||
<a href="{{ comment.edit_url }}" class="text-body-secondary small">{{ __('Edit') }}</a>
|
||||
<a href="{{ comment.edit_url|esc_url }}" class="text-body-secondary small">{{ __('Edit') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="comment-content small">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<ul class="list-unstyled">
|
||||
{% for item in footer_menu %}
|
||||
<li class="mb-1">
|
||||
<a href="{{ item.url }}" class="text-body-secondary text-decoration-none">
|
||||
<a href="{{ item.url|esc_url }}" class="text-body-secondary text-decoration-none">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<ul class="list-unstyled">
|
||||
{% for item in footer_menu %}
|
||||
<li>
|
||||
<a href="{{ item.url }}" class="text-body-secondary text-decoration-none">
|
||||
<a href="{{ item.url|esc_url }}" class="text-body-secondary text-decoration-none">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary" aria-label="{{ __('Primary navigation') }}">
|
||||
<div class="container flex-column">
|
||||
<a class="navbar-brand fw-bold mb-2" href="{{ site.url }}">
|
||||
<a class="navbar-brand fw-bold mb-2" href="{{ site.url|esc_url }}">
|
||||
{{ site.name }}
|
||||
</a>
|
||||
{% if site.description %}
|
||||
@@ -21,7 +21,7 @@
|
||||
{% if item.children|length > 0 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}" role="button"
|
||||
href="{{ item.url|esc_url }}" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
@@ -29,7 +29,7 @@
|
||||
{% for child in item.children %}
|
||||
<li>
|
||||
<a class="dropdown-item{{ child.active ? ' active' : '' }}"
|
||||
href="{{ child.url }}"
|
||||
href="{{ child.url|esc_url }}"
|
||||
{% if child.active %}aria-current="page"{% endif %}
|
||||
{% if child.target %}target="{{ child.target }}"{% endif %}>
|
||||
{{ child.title }}
|
||||
@@ -41,7 +41,7 @@
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}"
|
||||
href="{{ item.url|esc_url }}"
|
||||
{% if item.active %}aria-current="page"{% endif %}
|
||||
{% if item.target %}target="{{ item.target }}"{% endif %}>
|
||||
{{ item.title }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary" aria-label="{{ __('Primary navigation') }}">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url }}">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url|esc_url }}">
|
||||
{{ site.name }}
|
||||
</a>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
aria-labelledby="navbarOffcanvasLabel">
|
||||
<div class="offcanvas-header">
|
||||
{% if user.logged_in %}
|
||||
<a href="{{ user.account_url }}" class="d-flex align-items-center text-decoration-none">
|
||||
<a href="{{ user.account_url|esc_url }}" class="d-flex align-items-center text-decoration-none">
|
||||
{{ user.avatar|raw }}
|
||||
<span class="ms-2 fw-semibold">{{ user.display_name|esc_html }}</span>
|
||||
</a>
|
||||
@@ -32,7 +32,7 @@
|
||||
{% if item.children|length > 0 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}" role="button"
|
||||
href="{{ item.url|esc_url }}" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
@@ -40,7 +40,7 @@
|
||||
{% for child in item.children %}
|
||||
<li>
|
||||
<a class="dropdown-item{{ child.active ? ' active' : '' }}"
|
||||
href="{{ child.url }}"
|
||||
href="{{ child.url|esc_url }}"
|
||||
{% if child.active %}aria-current="page"{% endif %}
|
||||
{% if child.target %}target="{{ child.target }}"{% endif %}>
|
||||
{{ child.title }}
|
||||
@@ -52,7 +52,7 @@
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}"
|
||||
href="{{ item.url|esc_url }}"
|
||||
{% if item.active %}aria-current="page"{% endif %}
|
||||
{% if item.target %}target="{{ item.target }}"{% endif %}>
|
||||
{{ item.title }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<header class="position-absolute w-100" style="z-index: 1030;">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" aria-label="{{ __('Primary navigation') }}">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url }}">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url|esc_url }}">
|
||||
{{ site.name }}
|
||||
</a>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% if item.children|length > 0 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}" role="button"
|
||||
href="{{ item.url|esc_url }}" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
@@ -26,7 +26,7 @@
|
||||
{% for child in item.children %}
|
||||
<li>
|
||||
<a class="dropdown-item{{ child.active ? ' active' : '' }}"
|
||||
href="{{ child.url }}"
|
||||
href="{{ child.url|esc_url }}"
|
||||
{% if child.active %}aria-current="page"{% endif %}
|
||||
{% if child.target %}target="{{ child.target }}"{% endif %}>
|
||||
{{ child.title }}
|
||||
@@ -38,7 +38,7 @@
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}"
|
||||
href="{{ item.url|esc_url }}"
|
||||
{% if item.active %}aria-current="page"{% endif %}
|
||||
{% if item.target %}target="{{ item.target }}"{% endif %}>
|
||||
{{ item.title }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary" aria-label="{{ __('Primary navigation') }}">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url }}">
|
||||
<a class="navbar-brand fw-bold" href="{{ site.url|esc_url }}">
|
||||
{{ site.name }}
|
||||
</a>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% if item.children|length > 0 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}" role="button"
|
||||
href="{{ item.url|esc_url }}" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
@@ -26,7 +26,7 @@
|
||||
{% for child in item.children %}
|
||||
<li>
|
||||
<a class="dropdown-item{{ child.active ? ' active' : '' }}"
|
||||
href="{{ child.url }}"
|
||||
href="{{ child.url|esc_url }}"
|
||||
{% if child.active %}aria-current="page"{% endif %}
|
||||
{% if child.target %}target="{{ child.target }}"{% endif %}>
|
||||
{{ child.title }}
|
||||
@@ -38,7 +38,7 @@
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{{ item.active ? ' active' : '' }}"
|
||||
href="{{ item.url }}"
|
||||
href="{{ item.url|esc_url }}"
|
||||
{% if item.active %}aria-current="page"{% endif %}
|
||||
{% if item.target %}target="{{ item.target }}"{% endif %}>
|
||||
{{ item.title }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<form role="search" method="get" action="{{ site.url }}" class="mb-4">
|
||||
<form role="search" method="get" action="{{ site.url|esc_url }}" class="mb-4">
|
||||
<div class="input-group">
|
||||
<input type="search" class="form-control" name="s"
|
||||
placeholder="{{ __('Search...') }}"
|
||||
|
||||
Reference in New Issue
Block a user